From 08c1c941ebaf262ce63a9c722030d6473c670bf6 Mon Sep 17 00:00:00 2001 From: su-fen <715041@qq.com> Date: Sun, 19 Jul 2026 15:58:15 +0800 Subject: [PATCH] refactor(protocol)!: remove the v1 dual protocol (JSON WS + gRPC), serve v2 only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Execute the deletion planned in docs/architecture/protocol-v2-migration.md: the gateway now serves only the v2 unified wire protocol (/ws/v2 three links). Gateway (Go): - Delete internal/server/websocket*.go, grpc.go, shutdown.go, http_origin.go, auth/grpc_interceptor.go, gateway_grpc.pb.go and the v1 test suites - /ws and /ws/terminal now answer explicit 410 Gone - -grpc-addr becomes a deprecated no-op flag (warns at startup; removal next version, together with the always-zero v1_* protocol_usage keys kept for one release as a monitoring transition window) - Move the GitRequest enable_web_git write gating into pbws/guard.go (identical write-action list as v1), with new v2 gating tests - Move the publicHistoryShare proto→JSON shaping into proto_json.go (byte-identical logic; public share page contract unchanged) - proto/v1/gateway.proto: only the AgentGateway service is removed; all messages stay (they are the v2 payloads; field numbers never change) - Drop grpc deps from go.mod, protoc-gen-go-grpc from CI/mise/buf.gen, the SA1019 v1 exemptions from golangci, and port 50051 from Docker/Make Desktop (Rust): - Delete the gRPC fallback path (connect_and_serve_grpc, terminal stream, URL/endpoint builders); v2 handshake failures no longer fall back and follow the normal backoff-reconnect path - Collapse WsServeError/WsHandshakeError into Result<_, String> (the classification only served the deleted fallback decision) - Drop tonic/tonic-prost runtime deps: protos are generated as pure messages (build_client(false)) and included straight from OUT_DIR - Default remote gateway port 50051 → 443 (nothing listens on 50051 now) Frontends (both mirrors): - Remove the gRPC endpoint input; rename the preview builder to buildGatewayEndpointPreview; port default/placeholder 443; refresh copy Remaining renames kept on purpose: settings grpc_port (means the v2 gateway port) and config GRPCMaxMessageBytes (v2 read limit). --- .github/workflows/ci.yml | 1 - Cargo.lock | 81 -- Dockerfile | 3 +- Makefile | 5 +- README.md | 70 +- README.zh-CN.md | 69 +- crates/agent-gateway/.golangci.yml | 13 +- crates/agent-gateway/buf.gen.yaml | 3 - crates/agent-gateway/buf.yaml | 10 +- .../cmd/gateway/grpc_tls_test.go | 133 --- crates/agent-gateway/cmd/gateway/main.go | 77 +- crates/agent-gateway/cmd/gateway/shutdown.go | 33 - .../cmd/gateway/shutdown_test.go | 65 -- crates/agent-gateway/go.mod | 7 - crates/agent-gateway/go.sum | 30 - .../internal/auth/grpc_interceptor.go | 62 -- .../agent-gateway/internal/config/config.go | 8 +- .../internal/observability/protousage.go | 41 +- .../internal/proto/v1/gateway.pb.go | 18 +- .../internal/proto/v1/gateway_grpc.pb.go | 200 ---- .../internal/protocol/pbws/agent_conn.go | 6 +- .../internal/protocol/pbws/guard.go | 18 +- .../internal/protocol/pbws/server.go | 4 +- crates/agent-gateway/internal/server/grpc.go | 289 ------ .../internal/server/grpc_test.go | 219 ---- crates/agent-gateway/internal/server/http.go | 29 +- .../internal/server/http_origin.go | 13 - .../internal/server/http_test.go | 56 +- .../internal/server/proto_json.go | 106 ++ .../internal/server/proto_json_test.go | 40 + .../internal/server/websocket.go | 739 ------------- .../server/websocket_chat_handlers.go | 427 -------- .../server/websocket_chat_queue_handlers.go | 100 -- .../server/websocket_chat_shed_test.go | 84 -- .../server/websocket_connection_state_test.go | 106 -- .../server/websocket_cron_handlers.go | 52 - .../internal/server/websocket_fs_handlers.go | 626 ----------- .../internal/server/websocket_git_handlers.go | 70 -- .../server/websocket_history_handlers.go | 582 ----------- .../server/websocket_memory_handlers.go | 74 -- .../server/websocket_payload_bench_test.go | 92 -- .../internal/server/websocket_payload_test.go | 307 ------ .../internal/server/websocket_payloads.go | 436 -------- .../server/websocket_process_handlers.go | 169 --- .../server/websocket_provider_handlers.go | 89 -- .../internal/server/websocket_roundtrip.go | 54 - .../internal/server/websocket_routes.go | 119 --- .../internal/server/websocket_routes_test.go | 173 ---- .../server/websocket_settings_handlers.go | 147 --- .../server/websocket_sftp_handlers.go | 180 ---- .../server/websocket_sftp_handlers_test.go | 75 -- .../server/websocket_skills_handlers.go | 270 ----- .../server/websocket_terminal_handlers.go | 104 -- .../server/websocket_terminal_stream.go | 427 -------- .../server/websocket_tunnel_handlers.go | 166 --- .../server/websocket_upload_handlers.go | 58 -- .../server/websocket_workspace_handlers.go | 132 --- .../internal/server/websocket_write_test.go | 137 --- .../internal/session/conversation_ingress.go | 2 +- .../internal/session/manager_dispatch.go | 2 +- .../internal/session/tunnel_state.go | 2 +- .../internal/session/workspace_activity.go | 2 +- crates/agent-gateway/proto/v1/gateway.proto | 13 +- crates/agent-gateway/test/auth/auth_test.go | 51 - .../agent-gateway/test/websocket/chat_test.go | 424 -------- .../agent-gateway/test/websocket/git_test.go | 236 ----- .../test/websocket/liveness_test.go | 145 --- .../test/websocket/terminal_test.go | 973 ------------------ .../test/websocket/v2_git_gating_test.go | 106 ++ .../test/websocket/v2_helpers_test.go | 55 +- .../test/websocket/websocket_helpers_test.go | 130 --- crates/agent-gateway/web/src/i18n/config.ts | 19 +- .../src/lib/proto/gen/proto/v1/gateway_pb.ts | 42 +- .../web/src/lib/settings/index.ts | 4 +- .../web/src/pages/settings/RemoteSection.tsx | 47 +- crates/agent-gui/src-tauri/Cargo.toml | 2 - crates/agent-gui/src-tauri/build.rs | 7 +- .../src-tauri/src/commands/app/system.rs | 1 - .../src-tauri/src/commands/app/update.rs | 4 +- .../src-tauri/src/commands/automation/hook.rs | 5 +- .../src/commands/config/settings/remote.rs | 4 +- .../src-tauri/src/commands/integration/mcp.rs | 4 +- .../src-tauri/src/commands/workspace/fs.rs | 154 +-- .../src-tauri/src/commands/workspace/git.rs | 22 +- .../src-tauri/src/runtime/managed_process.rs | 19 +- .../src/runtime/managed_process_journal.rs | 4 +- .../src-tauri/src/runtime/shell_runner.rs | 5 +- .../src-tauri/src/services/automation/db.rs | 3 +- .../src/services/automation/scheduler.rs | 100 +- .../src/services/automation/store.rs | 23 +- .../src/services/automation/tests.rs | 31 +- .../src/services/automation/validate.rs | 25 +- .../src/services/gateway/connection.rs | 291 +----- .../src/services/gateway/managed_process.rs | 3 +- .../src-tauri/src/services/gateway/mod.rs | 16 +- .../src/services/gateway/terminal.rs | 188 +--- .../src-tauri/src/services/gateway/tests.rs | 88 +- .../src/services/gateway/ws_transport.rs | 184 ++-- .../src-tauri/src/services/gateway_bridge.rs | 46 +- .../src/services/skills/external_mcp.rs | 43 +- .../src-tauri/src/services/skills/manager.rs | 3 +- .../src-tauri/src/services/skills/metadata.rs | 5 +- .../src-tauri/src/services/skills/mod.rs | 2 +- .../src/services/workspace_watch/emit.rs | 5 +- .../src/services/workspace_watch/mod.rs | 7 +- .../src/services/workspace_watch/watcher.rs | 26 +- crates/agent-gui/src/i18n/config.ts | 15 +- crates/agent-gui/src/lib/settings/index.ts | 4 +- .../src/pages/settings/RemoteSection.tsx | 48 +- .../test/settings/normalization.test.mjs | 2 +- docs/README.md | 2 +- docs/architecture/gateway.md | 55 +- docs/architecture/gui.md | 6 +- docs/architecture/overview.md | 9 +- docs/architecture/protocol-v2-migration.md | 116 ++- docs/architecture/protocols.md | 75 +- docs/operations/deployment.md | 17 +- docs/operations/development.md | 9 +- docs/reference/source-map.md | 11 +- mise.toml | 1 - 120 files changed, 1029 insertions(+), 10618 deletions(-) delete mode 100644 crates/agent-gateway/cmd/gateway/grpc_tls_test.go delete mode 100644 crates/agent-gateway/cmd/gateway/shutdown.go delete mode 100644 crates/agent-gateway/cmd/gateway/shutdown_test.go delete mode 100644 crates/agent-gateway/internal/auth/grpc_interceptor.go delete mode 100644 crates/agent-gateway/internal/proto/v1/gateway_grpc.pb.go delete mode 100644 crates/agent-gateway/internal/server/grpc.go delete mode 100644 crates/agent-gateway/internal/server/grpc_test.go delete mode 100644 crates/agent-gateway/internal/server/http_origin.go create mode 100644 crates/agent-gateway/internal/server/proto_json.go create mode 100644 crates/agent-gateway/internal/server/proto_json_test.go delete mode 100644 crates/agent-gateway/internal/server/websocket.go delete mode 100644 crates/agent-gateway/internal/server/websocket_chat_handlers.go delete mode 100644 crates/agent-gateway/internal/server/websocket_chat_queue_handlers.go delete mode 100644 crates/agent-gateway/internal/server/websocket_chat_shed_test.go delete mode 100644 crates/agent-gateway/internal/server/websocket_connection_state_test.go delete mode 100644 crates/agent-gateway/internal/server/websocket_cron_handlers.go delete mode 100644 crates/agent-gateway/internal/server/websocket_fs_handlers.go delete mode 100644 crates/agent-gateway/internal/server/websocket_git_handlers.go delete mode 100644 crates/agent-gateway/internal/server/websocket_history_handlers.go delete mode 100644 crates/agent-gateway/internal/server/websocket_memory_handlers.go delete mode 100644 crates/agent-gateway/internal/server/websocket_payload_bench_test.go delete mode 100644 crates/agent-gateway/internal/server/websocket_payload_test.go delete mode 100644 crates/agent-gateway/internal/server/websocket_payloads.go delete mode 100644 crates/agent-gateway/internal/server/websocket_process_handlers.go delete mode 100644 crates/agent-gateway/internal/server/websocket_provider_handlers.go delete mode 100644 crates/agent-gateway/internal/server/websocket_roundtrip.go delete mode 100644 crates/agent-gateway/internal/server/websocket_routes.go delete mode 100644 crates/agent-gateway/internal/server/websocket_routes_test.go delete mode 100644 crates/agent-gateway/internal/server/websocket_settings_handlers.go delete mode 100644 crates/agent-gateway/internal/server/websocket_sftp_handlers.go delete mode 100644 crates/agent-gateway/internal/server/websocket_sftp_handlers_test.go delete mode 100644 crates/agent-gateway/internal/server/websocket_skills_handlers.go delete mode 100644 crates/agent-gateway/internal/server/websocket_terminal_handlers.go delete mode 100644 crates/agent-gateway/internal/server/websocket_terminal_stream.go delete mode 100644 crates/agent-gateway/internal/server/websocket_tunnel_handlers.go delete mode 100644 crates/agent-gateway/internal/server/websocket_upload_handlers.go delete mode 100644 crates/agent-gateway/internal/server/websocket_workspace_handlers.go delete mode 100644 crates/agent-gateway/internal/server/websocket_write_test.go delete mode 100644 crates/agent-gateway/test/websocket/chat_test.go delete mode 100644 crates/agent-gateway/test/websocket/git_test.go delete mode 100644 crates/agent-gateway/test/websocket/liveness_test.go delete mode 100644 crates/agent-gateway/test/websocket/terminal_test.go create mode 100644 crates/agent-gateway/test/websocket/v2_git_gating_test.go delete mode 100644 crates/agent-gateway/test/websocket/websocket_helpers_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 655d6701e..bd500c17c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,6 @@ jobs: - name: Install protobuf Go plugins run: | go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.11 - go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.2 echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" - name: Build embedded Gateway WebUI diff --git a/Cargo.lock b/Cargo.lock index 28ed9cc23..9a069572e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2501,19 +2501,6 @@ dependencies = [ "tower-service", ] -[[package]] -name = "hyper-timeout" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" -dependencies = [ - "hyper", - "hyper-util", - "pin-project-lite", - "tokio", - "tower-service", -] - [[package]] name = "hyper-util" version = "0.1.20" @@ -3216,8 +3203,6 @@ dependencies = [ "tokio-stream", "tokio-tungstenite 0.29.0", "toml 0.9.12+spec-1.1.0", - "tonic", - "tonic-prost", "tonic-prost-build", "uuid", "wait-timeout", @@ -4290,26 +4275,6 @@ dependencies = [ "siphasher", ] -[[package]] -name = "pin-project" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - [[package]] name = "pin-project-lite" version = "0.2.17" @@ -5239,7 +5204,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" dependencies = [ "aws-lc-rs", - "log", "once_cell", "ring", "rustls-pki-types", @@ -6869,37 +6833,6 @@ version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" -[[package]] -name = "tonic" -version = "0.14.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" -dependencies = [ - "async-trait", - "axum", - "base64 0.22.1", - "bytes", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-timeout", - "hyper-util", - "percent-encoding", - "pin-project", - "socket2", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tokio-stream", - "tower", - "tower-layer", - "tower-service", - "tracing", - "webpki-roots 1.0.8", -] - [[package]] name = "tonic-build" version = "0.14.6" @@ -6912,17 +6845,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "tonic-prost" -version = "0.14.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" -dependencies = [ - "bytes", - "prost", - "tonic", -] - [[package]] name = "tonic-prost-build" version = "0.14.6" @@ -6947,12 +6869,9 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", - "indexmap 2.14.0", "pin-project-lite", - "slab", "sync_wrapper", "tokio", - "tokio-util", "tower-layer", "tower-service", "tracing", diff --git a/Dockerfile b/Dockerfile index 320ac5bff..902fc6a69 100644 --- a/Dockerfile +++ b/Dockerfile @@ -41,8 +41,7 @@ COPY --from=gateway-builder /out/liveagent-gateway /usr/local/bin/liveagent-gate USER liveagent ENV PORT=8080 -ENV LIVEAGENT_GATEWAY_GRPC_ADDR=:50051 -EXPOSE 8080 50051 +EXPOSE 8080 ENTRYPOINT ["/usr/local/bin/liveagent-gateway"] diff --git a/Makefile b/Makefile index bd5e8fd14..e3571b888 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,6 @@ DESKTOP_RELEASE_TAURI_CONFIG_FLAGS ?= --config $(DESKTOP_RELEASE_TAURI_CONFIG) $ DEV_GATEWAY_TOKEN ?= dev-token DEV_GATEWAY_HTTP_ADDR ?= :50052 -DEV_GATEWAY_GRPC_ADDR ?= :50051 DEV_WEBUI_PROXY_API ?= http://localhost:50052 GATEWAY_DOCKER_IMAGE ?= liveagent-gateway:local RELEASE_TAG ?= @@ -108,7 +107,7 @@ check-github-release-tag: ## Gateway development dev-gateway: - go -C $(AGENT_GATEWAY_DIR) run ./cmd/gateway --token=$(DEV_GATEWAY_TOKEN) --http-addr=$(DEV_GATEWAY_HTTP_ADDR) --grpc-addr=$(DEV_GATEWAY_GRPC_ADDR) + go -C $(AGENT_GATEWAY_DIR) run ./cmd/gateway --token=$(DEV_GATEWAY_TOKEN) --http-addr=$(DEV_GATEWAY_HTTP_ADDR) dev-webui: npm_config_proxy_api=$(DEV_WEBUI_PROXY_API) pnpm --dir $(AGENT_GATEWAY_WEB_DIR) dev @@ -137,7 +136,7 @@ gateway-docker-build: docker build -t $(GATEWAY_DOCKER_IMAGE) . gateway-docker-run: - docker run --rm -p 8080:8080 -p 50051:50051 -e LIVEAGENT_GATEWAY_TOKEN=$(DEV_GATEWAY_TOKEN) $(GATEWAY_DOCKER_IMAGE) + docker run --rm -p 8080:8080 -e LIVEAGENT_GATEWAY_TOKEN=$(DEV_GATEWAY_TOKEN) $(GATEWAY_DOCKER_IMAGE) gateway-docker-smoke: gateway-docker-build @set -e; \ diff --git a/README.md b/README.md index 5df8762c3..190c12fdf 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ LiveAgent is a **local-first** AI agent desktop client. It deeply integrates lar ### 🌐 Remote Gateway -- **Access from any browser** — Go + gRPC gateway with a WebUI for remotely controlling the local agent +- **Access from any browser** — Go gateway (WebSocket + Protobuf) with a WebUI for remotely controlling the local agent - **Disconnect recovery** — a bounded seq window replays short outages, with desktop-side persistence as the safety net --- @@ -172,12 +172,11 @@ The desktop app works out of the box and depends on no server. Deploy the Gatewa # Pull the image (built by GitHub Actions, multi-arch: amd64 / arm64) docker pull ghcr.io/stack-cairn/liveagent-gateway:latest -# Run in the background (gRPC → host 50051 | HTTP/WebSocket → host 50052) +# Run in the background (HTTP/WebSocket → host 3000) docker run -d \ --name liveagent-gateway \ --restart unless-stopped \ - -p 50051:50051 \ - -p 50052:8080 \ + -p 3000:8080 \ -e LIVEAGENT_GATEWAY_TOKEN=your-token \ ghcr.io/stack-cairn/liveagent-gateway:latest ``` @@ -190,8 +189,7 @@ docker pull ghcr.io/stack-cairn/liveagent-gateway:latest \ && docker run -d \ --name liveagent-gateway \ --restart unless-stopped \ - -p 50051:50051 \ - -p 50052:8080 \ + -p 3000:8080 \ -e LIVEAGENT_GATEWAY_TOKEN=your-token \ ghcr.io/stack-cairn/liveagent-gateway:latest \ && docker image prune -f @@ -200,60 +198,36 @@ docker pull ghcr.io/stack-cairn/liveagent-gateway:latest \
Nginx reverse proxy configuration — reference for custom domains / TLS -> The Gateway serves two kinds of traffic: +> Since protocol v2, all traffic — the WebUI, the HTTP API, and the WebSocket links of both the browser and the desktop app — goes through the single HTTP port (default 3000). > -> the desktop app's bidirectional gRPC stream (default 50051) and the browser's HTTP / WebSocket (default 50052). -> -> When exposing through Nginx, proxy them separately. Both gRPC and WebSocket are long-lived connections, so raise the timeouts: +> WebSocket upgrades happen on several paths (`/ws/v2`, `/ws/v2/agent`, `/ws/v2/terminal`, and tunnels under `/t/`), so the simplest correct setup enables the upgrade on the whole vhost: ```nginx -# GUI Remote: gRPC Authenticate + AgentConnect -location /liveagent.gateway.v1.AgentGateway/ { - grpc_pass grpc://127.0.0.1:50051; - - grpc_set_header Host $host; - grpc_set_header Authorization $http_authorization; - grpc_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - grpc_set_header X-Forwarded-Proto $scheme; - - grpc_socket_keepalive on; - grpc_read_timeout 24h; - grpc_send_timeout 24h; -} - -# WebUI WebSocket -location = /ws { - proxy_pass http://127.0.0.1:50052; - +# WebUI SPA/static/API + every WebSocket link (browser and desktop) +location / { + proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; + + # WebSocket upgrade proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; + # Required: the Gateway's same-origin check compares the browser's + # Origin header against X-Forwarded-Proto + Host proxy_set_header Host $host; proxy_set_header Authorization $http_authorization; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; - proxy_read_timeout 24h; - proxy_send_timeout 24h; + # The Gateway pings every WebSocket connection every 15s, + # so a generous-but-finite timeout is enough + proxy_read_timeout 300s; + proxy_send_timeout 300s; proxy_buffering off; } - -# WebUI SPA/static/API -location / { - proxy_pass http://127.0.0.1:50052; - - proxy_set_header Host $host; - proxy_set_header Authorization $http_authorization; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - proxy_read_timeout 10m; - proxy_send_timeout 10m; -} ``` -> Upstream ports map one-to-one to the host ports from the `docker run` above: gRPC 50051, HTTP/WebSocket 50052 (inside the container, HTTP actually listens on `PORT=8080`). The gRPC proxy requires Nginx to accept the desktop connection over HTTP/2 (`listen 443 ssl; http2 on;`). +> The upstream port maps to the host port from the `docker run` above: HTTP/WebSocket 3000 (inside the container, HTTP actually listens on `PORT=8080`). The server block needs `listen 443 ssl;` and a `client_max_body_size` large enough for attachment uploads (e.g. `100m`).
@@ -278,10 +252,10 @@ Expand the Development Guide below for the full set of Make commands. │ WebSocket / HTTP ┌────────────────────────────▼─────────────────────────────────┐ │ Agent Gateway │ -│ Go · gRPC · HTTP · Session Manager · Event Store │ +│ Go · WebSocket · HTTP · Session Manager · Event Store │ │ (Railway / Docker / self-hosted) │ └────────────────────────────┬─────────────────────────────────┘ - │ gRPC (bidirectional stream) + │ WebSocket v2 (bidirectional stream) ┌────────────────────────────▼─────────────────────────────────┐ │ Agent GUI │ │ Tauri 2 · React 19 · Rust │ @@ -300,10 +274,10 @@ Expand the Development Guide below for the full set of Make commands. | **Agent GUI** · Build | Vite 8 + pnpm | | **Agent GUI** · Styling | Tailwind CSS 4 + Radix UI | | **Agent GUI** · Rendering | streamdown + KaTeX + Mermaid + Monaco Editor | -| **Agent GUI** · Backend | Rust + Tokio + SQLite (rusqlite) + gRPC (tonic) | +| **Agent GUI** · Backend | Rust + Tokio + SQLite (rusqlite) + WebSocket (tokio-tungstenite) | | **Agent GUI** · LLM | @earendil-works/pi-ai · @openai/codex-sdk · claude-agent-sdk | | **Gateway** · Language | Go 1.25 | -| **Gateway** · Protocols | gRPC + Protobuf + HTTP + WebSocket | +| **Gateway** · Protocols | WebSocket + Protobuf + HTTP | | **Gateway** · Web UI | React + Vite + Tailwind CSS (embedded) | | **Gateway** · Deployment | Docker multi-stage · Railway CI/CD | diff --git a/README.zh-CN.md b/README.zh-CN.md index c09ba4196..e5af57810 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -117,7 +117,7 @@ LiveAgent 是一个 **本地优先** 的 AI Agent 桌面客户端。它将大语 ### 🌐 远程 Gateway -- **浏览器随处访问** — Go + gRPC 网关,WebUI 远程操控本地 Agent +- **浏览器随处访问** — Go 网关(WebSocket + Protobuf),WebUI 远程操控本地 Agent - **断线可恢复** — 有界 seq window 补齐短时断线,桌面端持久化兜底 --- @@ -173,12 +173,11 @@ LiveAgent 是一个 **本地优先** 的 AI Agent 桌面客户端。它将大语 # 拉取镜像(GitHub Actions 自动构建,multi-arch: amd64 / arm64) docker pull ghcr.io/stack-cairn/liveagent-gateway:latest -# 后台运行(gRPC → 宿主机 50051 | HTTP/WebSocket → 宿主机 50052) +# 后台运行(HTTP/WebSocket → 宿主机 3000) docker run -d \ --name liveagent-gateway \ --restart unless-stopped \ - -p 50051:50051 \ - -p 50052:8080 \ + -p 3000:8080 \ -e LIVEAGENT_GATEWAY_TOKEN=your-token \ ghcr.io/stack-cairn/liveagent-gateway:latest ``` @@ -191,8 +190,7 @@ docker pull ghcr.io/stack-cairn/liveagent-gateway:latest \ && docker run -d \ --name liveagent-gateway \ --restart unless-stopped \ - -p 50051:50051 \ - -p 50052:8080 \ + -p 3000:8080 \ -e LIVEAGENT_GATEWAY_TOKEN=your-token \ ghcr.io/stack-cairn/liveagent-gateway:latest \ && docker image prune -f @@ -201,60 +199,35 @@ docker pull ghcr.io/stack-cairn/liveagent-gateway:latest \
Nginx 反向代理配置 — 自建域名 / TLS 时参考 -> Gateway 对外有两类流量: +> 自 v2 协议起,WebUI、HTTP API 以及浏览器端和桌面端的 WebSocket 链路全部走同一个 HTTP 端口(默认 3000)。 > -> 桌面端的 gRPC 双向流 (默认 50051) 与浏览器端的 HTTP / WebSocket (默认 50052)。 -> -> 经 Nginx 暴露时需要分别代理,注意 gRPC 与 WebSocket 均为长连接,超时需调大: +> WebSocket 升级发生在多个路径上(`/ws/v2`、`/ws/v2/agent`、`/ws/v2/terminal`,以及 `/t/` 下的隧道),最省事且正确的做法是在整个 vhost 上启用升级: ```nginx -# GUI Remote: gRPC Authenticate + AgentConnect -location /liveagent.gateway.v1.AgentGateway/ { - grpc_pass grpc://127.0.0.1:50051; - - grpc_set_header Host $host; - grpc_set_header Authorization $http_authorization; - grpc_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - grpc_set_header X-Forwarded-Proto $scheme; - - grpc_socket_keepalive on; - grpc_read_timeout 24h; - grpc_send_timeout 24h; -} - -# WebUI WebSocket -location = /ws { - proxy_pass http://127.0.0.1:50052; - +# WebUI SPA/静态资源/API + 全部 WebSocket 链路(浏览器端与桌面端) +location / { + proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; + + # WebSocket 升级 proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; + # 必须透传:Gateway 的同源校验会拿浏览器的 Origin 头 + # 与 X-Forwarded-Proto + Host 做比对 proxy_set_header Host $host; proxy_set_header Authorization $http_authorization; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; - proxy_read_timeout 24h; - proxy_send_timeout 24h; + # Gateway 每 15s 主动向每条 WebSocket 连接发 Ping,超时给足冗余即可 + proxy_read_timeout 300s; + proxy_send_timeout 300s; proxy_buffering off; } - -# WebUI SPA/static/API -location / { - proxy_pass http://127.0.0.1:50052; - - proxy_set_header Host $host; - proxy_set_header Authorization $http_authorization; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - proxy_read_timeout 10m; - proxy_send_timeout 10m; -} ``` -> 上游端口与上方 `docker run` 的宿主机映射一一对应:gRPC 50051、HTTP/WebSocket 50052(容器内 HTTP 实际监听 `PORT=8080`)。gRPC 代理要求 Nginx 以 HTTP/2 接收桌面端连接(`listen 443 ssl; http2 on;`)。 +> 上游端口与上方 `docker run` 的宿主机映射对应:HTTP/WebSocket 3000(容器内 HTTP 实际监听 `PORT=8080`)。server 块需要 `listen 443 ssl;`,并把 `client_max_body_size` 调大到足够容纳附件上传(如 `100m`)。
@@ -279,10 +252,10 @@ location / { │ WebSocket / HTTP ┌────────────────────────────▼─────────────────────────────────┐ │ Agent Gateway │ -│ Go · gRPC · HTTP · Session Manager · Event Store │ +│ Go · WebSocket · HTTP · Session Manager · Event Store │ │ (Railway / Docker / 自部署) │ └────────────────────────────┬─────────────────────────────────┘ - │ gRPC (双向流) + │ WebSocket v2 (双向流) ┌────────────────────────────▼─────────────────────────────────┐ │ Agent GUI │ │ Tauri 2 · React 19 · Rust │ @@ -301,10 +274,10 @@ location / { | **Agent GUI** · 构建 | Vite 8 + pnpm | | **Agent GUI** · 样式 | Tailwind CSS 4 + Radix UI | | **Agent GUI** · 渲染 | streamdown + KaTeX + Mermaid + Monaco Editor | -| **Agent GUI** · 后端 | Rust + Tokio + SQLite (rusqlite) + gRPC (tonic) | +| **Agent GUI** · 后端 | Rust + Tokio + SQLite (rusqlite) + WebSocket (tokio-tungstenite) | | **Agent GUI** · LLM | @earendil-works/pi-ai · @openai/codex-sdk · claude-agent-sdk | | **Gateway** · 语言 | Go 1.25 | -| **Gateway** · 协议 | gRPC + Protobuf + HTTP + WebSocket | +| **Gateway** · 协议 | WebSocket + Protobuf + HTTP | | **Gateway** · Web UI | React + Vite + Tailwind CSS(嵌入式) | | **Gateway** · 部署 | Docker multi-stage · Railway CI/CD | diff --git a/crates/agent-gateway/.golangci.yml b/crates/agent-gateway/.golangci.yml index 97bd44c7c..b73acf17d 100644 --- a/crates/agent-gateway/.golangci.yml +++ b/crates/agent-gateway/.golangci.yml @@ -1,5 +1,5 @@ # golangci-lint v2 —— 网关 Go 代码检查基线(此前无 linter,选务实检查集):govet/staticcheck/unused -# 管正确性与死代码(SA1019 兼作"调用已弃用 v1 符号"提示),errcheck/ineffassign/misspell 做基础卫生。 +# 管正确性与死代码,errcheck/ineffassign/misspell 做基础卫生。 # 生成代码(internal/proto/)不参与检查。 version: "2" @@ -15,17 +15,6 @@ linters: exclusions: generated: lax rules: - # v1 协议实现、其组装点与测试引用弃用 v1 符号是预期行为(整层同生命周期弃用): - # 对这些路径静默 SA1019,避免刷屏。 - - path: internal/server/ - linters: [staticcheck] - text: "SA1019" - - path: cmd/gateway/ - linters: [staticcheck] - text: "SA1019" - - path: test/ - linters: [staticcheck] - text: "SA1019" # 测试代码允许忽略清理类调用的返回值。 - path: _test\.go linters: [errcheck] diff --git a/crates/agent-gateway/buf.gen.yaml b/crates/agent-gateway/buf.gen.yaml index 8955dd96f..5ff20981f 100644 --- a/crates/agent-gateway/buf.gen.yaml +++ b/crates/agent-gateway/buf.gen.yaml @@ -5,9 +5,6 @@ plugins: - local: protoc-gen-go out: . opt: module=github.com/liveagent/agent-gateway - - local: protoc-gen-go-grpc - out: . - opt: module=github.com/liveagent/agent-gateway - local: web/node_modules/.bin/protoc-gen-es out: web/src/lib/proto/gen opt: target=ts diff --git a/crates/agent-gateway/buf.yaml b/crates/agent-gateway/buf.yaml index 762fa006e..65efaa318 100644 --- a/crates/agent-gateway/buf.yaml +++ b/crates/agent-gateway/buf.yaml @@ -15,19 +15,11 @@ lint: # v1 契约早于 buf lint,历史命名不翻新(wire 兼容优先);豁免为全量 lint 确认后的最小集, # 仅限 v1 文件——v2 及新增 proto 必须完整满足 STANDARD。 ignore_only: - SERVICE_SUFFIX: - - proto/v1/gateway.proto - RPC_REQUEST_STANDARD_NAME: - - proto/v1/gateway.proto - RPC_RESPONSE_STANDARD_NAME: - - proto/v1/gateway.proto - RPC_REQUEST_RESPONSE_UNIQUE: - - proto/v1/gateway.proto ENUM_VALUE_PREFIX: - proto/v1/gateway.proto ENUM_ZERO_VALUE_SUFFIX: - proto/v1/gateway.proto breaking: use: - # WIRE_JSON:兼守二进制与 JSON 线格式兼容(v1 JSON 协议仍在服务期)。 + # WIRE_JSON:兼守二进制与 JSON 线格式兼容(chat 事件与公开分享页仍走 JSON 载荷)。 - WIRE_JSON diff --git a/crates/agent-gateway/cmd/gateway/grpc_tls_test.go b/crates/agent-gateway/cmd/gateway/grpc_tls_test.go deleted file mode 100644 index 3e2508fca..000000000 --- a/crates/agent-gateway/cmd/gateway/grpc_tls_test.go +++ /dev/null @@ -1,133 +0,0 @@ -package main - -import ( - "context" - "crypto/rand" - "crypto/rsa" - "crypto/tls" - "crypto/x509" - "crypto/x509/pkix" - "encoding/pem" - "errors" - "math/big" - "net" - "os" - "path/filepath" - "testing" - "time" - - "github.com/liveagent/agent-gateway/internal/config" - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" - "github.com/liveagent/agent-gateway/internal/session" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials" -) - -func TestNewGRPCServerUsesTLSWhenCertificateConfigured(t *testing.T) { - certFile, keyFile, certPEM := writeTestCertificate(t) - cfg := &config.Config{ - Token: "secret-token", - TLSCert: certFile, - TLSKey: keyFile, - GRPCMaxMessageBytes: config.DefaultGRPCMaxMessageBytes, - } - - grpcServer, err := newGRPCServer(cfg, session.NewManager()) - if err != nil { - t.Fatalf("newGRPCServer returned error: %v", err) - } - listener, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("listen: %v", err) - } - t.Cleanup(func() { - grpcServer.Stop() - _ = listener.Close() - }) - - serveErr := make(chan error, 1) - go func() { - serveErr <- grpcServer.Serve(listener) - }() - - roots := x509.NewCertPool() - if !roots.AppendCertsFromPEM(certPEM) { - t.Fatalf("failed to load test certificate") - } - creds := credentials.NewTLS(&tls.Config{ - MinVersion: tls.VersionTLS12, - RootCAs: roots, - ServerName: "localhost", - }) - conn, err := grpc.NewClient(listener.Addr().String(), grpc.WithTransportCredentials(creds)) - if err != nil { - t.Fatalf("dial TLS gRPC: %v", err) - } - t.Cleanup(func() { - _ = conn.Close() - }) - - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - resp, err := gatewayv1.NewAgentGatewayClient(conn).Authenticate(ctx, &gatewayv1.AuthRequest{ - Token: " secret-token\r\n", - AgentId: "test-agent", - AgentVersion: "test", - }) - if err != nil { - t.Fatalf("Authenticate over TLS failed: %v", err) - } - if !resp.GetSuccess() { - t.Fatalf("Authenticate success = false, message = %q", resp.GetMessage()) - } - - grpcServer.Stop() - select { - case err := <-serveErr: - if err != nil && !errors.Is(err, grpc.ErrServerStopped) { - t.Fatalf("Serve returned error: %v", err) - } - case <-time.After(3 * time.Second): - t.Fatalf("gRPC server did not stop") - } -} - -func writeTestCertificate(t *testing.T) (string, string, []byte) { - t.Helper() - - key, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - t.Fatalf("generate key: %v", err) - } - template := &x509.Certificate{ - SerialNumber: big.NewInt(1), - Subject: pkix.Name{ - CommonName: "localhost", - }, - NotBefore: time.Now().Add(-time.Hour), - NotAfter: time.Now().Add(time.Hour), - KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, - ExtKeyUsage: []x509.ExtKeyUsage{ - x509.ExtKeyUsageServerAuth, - }, - DNSNames: []string{"localhost"}, - IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, - } - der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) - if err != nil { - t.Fatalf("create certificate: %v", err) - } - certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) - keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) - - dir := t.TempDir() - certFile := filepath.Join(dir, "server.crt") - keyFile := filepath.Join(dir, "server.key") - if err := os.WriteFile(certFile, certPEM, 0o600); err != nil { - t.Fatalf("write cert: %v", err) - } - if err := os.WriteFile(keyFile, keyPEM, 0o600); err != nil { - t.Fatalf("write key: %v", err) - } - return certFile, keyFile, certPEM -} diff --git a/crates/agent-gateway/cmd/gateway/main.go b/crates/agent-gateway/cmd/gateway/main.go index 6444d332a..613994b83 100644 --- a/crates/agent-gateway/cmd/gateway/main.go +++ b/crates/agent-gateway/cmd/gateway/main.go @@ -4,28 +4,18 @@ import ( "context" "errors" "log/slog" - "net" "net/http" "os" "os/signal" "syscall" "time" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials" - "google.golang.org/grpc/keepalive" - "google.golang.org/grpc/reflection" - - "github.com/liveagent/agent-gateway/internal/auth" "github.com/liveagent/agent-gateway/internal/config" "github.com/liveagent/agent-gateway/internal/observability" - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" "github.com/liveagent/agent-gateway/internal/server" "github.com/liveagent/agent-gateway/internal/session" ) -const grpcShutdownTimeout = 3 * time.Second - // fatal 记录错误并以非零码退出(slog 没有 Fatal 级别,集中在此处理)。 func fatal(msg string, args ...any) { slog.Error(msg, args...) @@ -35,17 +25,11 @@ func fatal(msg string, args ...any) { func main() { observability.SetupLogging() cfg := config.Load() - sm := session.NewManager() - - grpcServer, err := newGRPCServer(cfg, sm) - if err != nil { - fatal("create gRPC server failed", "err", err) - } - - grpcListener, err := net.Listen("tcp", cfg.GRPCAddr) - if err != nil { - fatal("listen gRPC failed", "addr", cfg.GRPCAddr, "err", err) + //nolint:staticcheck // 读取弃用字段正是为了对旧启动脚本发出弃用警告。 + if cfg.GRPCAddr != "" { + slog.Warn("-grpc-addr is deprecated and ignored: the v1 gRPC listener was removed; desktop clients connect via /ws/v2/agent on the HTTP port") } + sm := session.NewManager() httpServer := &http.Server{ Addr: cfg.HTTPAddr, @@ -53,14 +37,7 @@ func main() { ReadHeaderTimeout: 10 * time.Second, } - errCh := make(chan error, 2) - - go func() { - slog.Info("gRPC listening", "addr", cfg.GRPCAddr) - if serveErr := grpcServer.Serve(grpcListener); serveErr != nil && !errors.Is(serveErr, grpc.ErrServerStopped) { - errCh <- serveErr - } - }() + errCh := make(chan error, 1) go func() { slog.Info("HTTP listening", "addr", cfg.HTTPAddr) @@ -88,49 +65,7 @@ func main() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - httpShutdownErrCh := make(chan error, 1) - go func() { - httpShutdownErrCh <- httpServer.Shutdown(ctx) - }() - - if forced := shutdownGRPCServer(grpcServer, grpcShutdownTimeout); forced { - slog.Warn("gRPC graceful shutdown timed out, forcing stop", "timeout", grpcShutdownTimeout) - } - - if err := <-httpShutdownErrCh; err != nil { + if err := httpServer.Shutdown(ctx); err != nil { slog.Warn("http shutdown error", "err", err) } } - -func newGRPCServer(cfg *config.Config, sm *session.Manager) (*grpc.Server, error) { - options := []grpc.ServerOption{ - grpc.MaxRecvMsgSize(cfg.GRPCMaxMessageBytes), - grpc.MaxSendMsgSize(cfg.GRPCMaxMessageBytes), - grpc.UnaryInterceptor(auth.GRPCUnaryInterceptor(cfg.Token)), - grpc.StreamInterceptor(auth.GRPCStreamInterceptor(cfg.Token)), - // Transport-level liveness: h2 PINGs are not subject to application - // queue congestion, so dead links are detected even mid-stream. - grpc.KeepaliveParams(keepalive.ServerParameters{ - Time: 30 * time.Second, - Timeout: 10 * time.Second, - }), - // The desktop GUI pings every 10-60s; MinTime must stay below its - // floor or grpc-go answers with a too_many_pings GOAWAY. - grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{ - MinTime: 5 * time.Second, - PermitWithoutStream: true, - }), - } - if cfg.TLSCert != "" || cfg.TLSKey != "" { - creds, err := credentials.NewServerTLSFromFile(cfg.TLSCert, cfg.TLSKey) - if err != nil { - return nil, err - } - options = append(options, grpc.Creds(creds)) - } - - grpcServer := grpc.NewServer(options...) - gatewayv1.RegisterAgentGatewayServer(grpcServer, server.NewGRPCServer(cfg, sm)) - reflection.Register(grpcServer) - return grpcServer, nil -} diff --git a/crates/agent-gateway/cmd/gateway/shutdown.go b/crates/agent-gateway/cmd/gateway/shutdown.go deleted file mode 100644 index a7a708741..000000000 --- a/crates/agent-gateway/cmd/gateway/shutdown.go +++ /dev/null @@ -1,33 +0,0 @@ -package main - -import "time" - -type grpcShutdownServer interface { - GracefulStop() - Stop() -} - -func shutdownGRPCServer(server grpcShutdownServer, timeout time.Duration) bool { - done := make(chan struct{}) - - go func() { - server.GracefulStop() - close(done) - }() - - if timeout <= 0 { - <-done - return false - } - - timer := time.NewTimer(timeout) - defer timer.Stop() - - select { - case <-done: - return false - case <-timer.C: - server.Stop() - return true - } -} diff --git a/crates/agent-gateway/cmd/gateway/shutdown_test.go b/crates/agent-gateway/cmd/gateway/shutdown_test.go deleted file mode 100644 index 37cca1e3b..000000000 --- a/crates/agent-gateway/cmd/gateway/shutdown_test.go +++ /dev/null @@ -1,65 +0,0 @@ -package main - -import ( - "sync" - "testing" - "time" -) - -type fakeGRPCServer struct { - gracefulDone chan struct{} - stopCalled chan struct{} - stopOnce sync.Once -} - -func newFakeGRPCServer() *fakeGRPCServer { - return &fakeGRPCServer{ - gracefulDone: make(chan struct{}), - stopCalled: make(chan struct{}), - } -} - -func (f *fakeGRPCServer) GracefulStop() { - <-f.gracefulDone -} - -func (f *fakeGRPCServer) Stop() { - f.stopOnce.Do(func() { - close(f.stopCalled) - close(f.gracefulDone) - }) -} - -func TestShutdownGRPCServerGraceful(t *testing.T) { - server := newFakeGRPCServer() - close(server.gracefulDone) - - if forced := shutdownGRPCServer(server, 50*time.Millisecond); forced { - t.Fatalf("expected graceful shutdown without forcing stop") - } - - select { - case <-server.stopCalled: - t.Fatalf("did not expect Stop to be called") - default: - } -} - -func TestShutdownGRPCServerForcesStopAfterTimeout(t *testing.T) { - server := newFakeGRPCServer() - - start := time.Now() - if forced := shutdownGRPCServer(server, 20*time.Millisecond); !forced { - t.Fatalf("expected forced shutdown after timeout") - } - - select { - case <-server.stopCalled: - case <-time.After(200 * time.Millisecond): - t.Fatalf("expected Stop to be called after timeout") - } - - if time.Since(start) < 20*time.Millisecond { - t.Fatalf("shutdown returned before timeout elapsed") - } -} diff --git a/crates/agent-gateway/go.mod b/crates/agent-gateway/go.mod index c55b26c66..04af49f44 100644 --- a/crates/agent-gateway/go.mod +++ b/crates/agent-gateway/go.mod @@ -9,12 +9,5 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/tdewolff/parse/v2 v2.8.13 golang.org/x/net v0.57.0 - google.golang.org/grpc v1.82.0 google.golang.org/protobuf v1.36.11 ) - -require ( - golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.40.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 // indirect -) diff --git a/crates/agent-gateway/go.sum b/crates/agent-gateway/go.sum index deb09bfe2..2febc8095 100644 --- a/crates/agent-gateway/go.sum +++ b/crates/agent-gateway/go.sum @@ -1,15 +1,7 @@ -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/doyensec/safeurl v0.2.5 h1:kKu0JNQy0tJ8jkDyB5h6Aml9vWWniq+mpoa12EGLcOQ= github.com/doyensec/safeurl v0.2.5/go.mod h1:3H0cgRpPYPSpgxRRn5yGD35Ns/LgGX/BVWSBbzUqXtY= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= -github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -20,29 +12,7 @@ github.com/tdewolff/parse/v2 v2.8.13 h1:si/8rLw5BZZTWCCiMm9A3f6x+RmqYfrkEeXCgpX5 github.com/tdewolff/parse/v2 v2.8.13/go.mod h1:XdsoSFThlVIRIajAuqz1evNY7bagZS8LBOPA3aVopwQ= github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk= github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= -go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= -go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= -go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= -go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= -go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= -go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= -go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= -go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= -go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= -gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= -gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 h1:qEHAMpSaUhtD0p3NbEEI83HwNGFxEwaSJ1G9PLnCBZE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= -google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/crates/agent-gateway/internal/auth/grpc_interceptor.go b/crates/agent-gateway/internal/auth/grpc_interceptor.go deleted file mode 100644 index 6ff8f60a2..000000000 --- a/crates/agent-gateway/internal/auth/grpc_interceptor.go +++ /dev/null @@ -1,62 +0,0 @@ -package auth - -import ( - "context" - - "google.golang.org/grpc" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/metadata" - "google.golang.org/grpc/status" -) - -const authenticateMethod = "/liveagent.gateway.v1.AgentGateway/Authenticate" - -func GRPCUnaryInterceptor(expectedToken string) grpc.UnaryServerInterceptor { - return func( - ctx context.Context, - req any, - info *grpc.UnaryServerInfo, - handler grpc.UnaryHandler, - ) (any, error) { - if info.FullMethod != authenticateMethod && !validateMetadataToken(ctx, expectedToken) { - return nil, status.Error(codes.Unauthenticated, "invalid token") - } - return handler(ctx, req) - } -} - -func GRPCStreamInterceptor(expectedToken string) grpc.StreamServerInterceptor { - return func( - srv any, - stream grpc.ServerStream, - info *grpc.StreamServerInfo, - handler grpc.StreamHandler, - ) error { - if !validateMetadataToken(stream.Context(), expectedToken) { - return status.Error(codes.Unauthenticated, "invalid token") - } - return handler(srv, stream) - } -} - -func validateMetadataToken(ctx context.Context, expectedToken string) bool { - md, ok := metadata.FromIncomingContext(ctx) - if !ok { - return false - } - if values := md.Get("authorization"); len(values) > 0 { - for _, value := range values { - if ValidateBearerHeader(value, expectedToken) { - return true - } - } - } - if values := md.Get("token"); len(values) > 0 { - for _, value := range values { - if ValidateToken(value, expectedToken) { - return true - } - } - } - return false -} diff --git a/crates/agent-gateway/internal/config/config.go b/crates/agent-gateway/internal/config/config.go index edefc1d11..b68dfaacf 100644 --- a/crates/agent-gateway/internal/config/config.go +++ b/crates/agent-gateway/internal/config/config.go @@ -12,7 +12,6 @@ const DefaultGRPCMaxMessageBytes = 64 * 1024 * 1024 type Config struct { Token string - GRPCAddr string HTTPAddr string TLSCert string TLSKey string @@ -29,6 +28,11 @@ type Config struct { GRPCMaxMessageBytes int RelayBufferSeconds int + // GRPCAddr is accepted but unused. + // + // Deprecated: v1 gRPC 链路已随协议 v2 统一移除,网关不再监听 gRPC 端口;保留本 flag 仅为不破坏既有启动脚本,下个版本删除。 + GRPCAddr string + // CommandQueueTimeout is accepted but unused. // // Deprecated: 离线命令队列(生产不可达路径)已随死代码清理移除;保留本 flag 仅为不破坏既有启动脚本,下个版本删除。 @@ -39,7 +43,7 @@ func Load() *Config { cfg := &Config{} flag.StringVar(&cfg.Token, "token", getenv("LIVEAGENT_GATEWAY_TOKEN", ""), "shared authentication token") - flag.StringVar(&cfg.GRPCAddr, "grpc-addr", getenv("LIVEAGENT_GATEWAY_GRPC_ADDR", ":50051"), "gRPC listen address") + flag.StringVar(&cfg.GRPCAddr, "grpc-addr", getenv("LIVEAGENT_GATEWAY_GRPC_ADDR", ""), "deprecated, no-op (v1 gRPC removed; kept for startup-script compatibility)") flag.StringVar(&cfg.HTTPAddr, "http-addr", getenv("LIVEAGENT_GATEWAY_HTTP_ADDR", defaultHTTPAddr()), "HTTP listen address") flag.StringVar(&cfg.TLSCert, "tls-cert", getenv("LIVEAGENT_GATEWAY_TLS_CERT", ""), "TLS certificate path") flag.StringVar(&cfg.TLSKey, "tls-key", getenv("LIVEAGENT_GATEWAY_TLS_KEY", ""), "TLS private key path") diff --git a/crates/agent-gateway/internal/observability/protousage.go b/crates/agent-gateway/internal/observability/protousage.go index 2e7da55e2..6dcb111f9 100644 --- a/crates/agent-gateway/internal/observability/protousage.go +++ b/crates/agent-gateway/internal/observability/protousage.go @@ -2,17 +2,9 @@ package observability import "sync/atomic" -// ProtoUsage 统计 v1(已弃用)与 v2 协议链路使用量:进程内原子计数,经 /api/status 的 -// protocol_usage 字段暴露;v1 计数停增且 active 归零即可安全删除 v1 代码。 +// ProtoUsage 统计 v2 协议链路使用量:进程内原子计数,经 /api/status 的 +// protocol_usage 字段暴露。(v1 计数器已随 v1 协议移除。) type ProtoUsage struct { - V1WSConnectionsTotal atomic.Int64 - V1WSConnectionsActive atomic.Int64 - V1WSRequestsTotal atomic.Int64 - V1TerminalWSConnectionsTotal atomic.Int64 - V1GRPCAgentConnectsTotal atomic.Int64 - V1GRPCAgentActive atomic.Int64 - V1GRPCTerminalConnectsTotal atomic.Int64 - V2BrowserConnectionsTotal atomic.Int64 V2BrowserConnectionsActive atomic.Int64 V2BrowserRequestsTotal atomic.Int64 @@ -27,18 +19,21 @@ var Usage ProtoUsage // Snapshot 导出当前计数(键名即对外 JSON 字段名)。 func (u *ProtoUsage) Snapshot() map[string]int64 { return map[string]int64{ - "v1_ws_connections_total": u.V1WSConnectionsTotal.Load(), - "v1_ws_connections_active": u.V1WSConnectionsActive.Load(), - "v1_ws_requests_total": u.V1WSRequestsTotal.Load(), - "v1_terminal_ws_connections_total": u.V1TerminalWSConnectionsTotal.Load(), - "v1_grpc_agent_connects_total": u.V1GRPCAgentConnectsTotal.Load(), - "v1_grpc_agent_active": u.V1GRPCAgentActive.Load(), - "v1_grpc_terminal_connects_total": u.V1GRPCTerminalConnectsTotal.Load(), - "v2_browser_connections_total": u.V2BrowserConnectionsTotal.Load(), - "v2_browser_connections_active": u.V2BrowserConnectionsActive.Load(), - "v2_browser_requests_total": u.V2BrowserRequestsTotal.Load(), - "v2_agent_connects_total": u.V2AgentConnectsTotal.Load(), - "v2_agent_active": u.V2AgentActive.Load(), - "v2_terminal_connects_total": u.V2TerminalConnectsTotal.Load(), + // Deprecated: v1 协议已删除,这些键恒为 0。保留一个版本给仍在读取 v1 计数的 + // 外部监控/升级门禁一个过渡窗口(0 即"v1 流量为零",语义真实),下个版本删除。 + "v1_ws_connections_total": 0, + "v1_ws_connections_active": 0, + "v1_ws_requests_total": 0, + "v1_terminal_ws_connections_total": 0, + "v1_grpc_agent_connects_total": 0, + "v1_grpc_agent_active": 0, + "v1_grpc_terminal_connects_total": 0, + + "v2_browser_connections_total": u.V2BrowserConnectionsTotal.Load(), + "v2_browser_connections_active": u.V2BrowserConnectionsActive.Load(), + "v2_browser_requests_total": u.V2BrowserRequestsTotal.Load(), + "v2_agent_connects_total": u.V2AgentConnectsTotal.Load(), + "v2_agent_active": u.V2AgentActive.Load(), + "v2_terminal_connects_total": u.V2TerminalConnectsTotal.Load(), } } diff --git a/crates/agent-gateway/internal/proto/v1/gateway.pb.go b/crates/agent-gateway/internal/proto/v1/gateway.pb.go index 6877e64f9..f8445940c 100644 --- a/crates/agent-gateway/internal/proto/v1/gateway.pb.go +++ b/crates/agent-gateway/internal/proto/v1/gateway.pb.go @@ -11944,11 +11944,7 @@ const file_proto_v1_gateway_proto_rawDesc = "" + "\x13TunnelWsMessageType\x12&\n" + "\"TUNNEL_WS_MESSAGE_TYPE_UNSPECIFIED\x10\x00\x12\x1f\n" + "\x1bTUNNEL_WS_MESSAGE_TYPE_TEXT\x10\x01\x12!\n" + - "\x1dTUNNEL_WS_MESSAGE_TYPE_BINARY\x10\x022\xbc\x02\n" + - "\fAgentGateway\x12^\n" + - "\fAgentConnect\x12#.liveagent.gateway.v1.AgentEnvelope\x1a%.liveagent.gateway.v1.GatewayEnvelope(\x010\x01\x12p\n" + - "\x14AgentTerminalConnect\x12).liveagent.gateway.v1.TerminalStreamFrame\x1a).liveagent.gateway.v1.TerminalStreamFrame(\x010\x01\x12U\n" + - "\fAuthenticate\x12!.liveagent.gateway.v1.AuthRequest\x1a\".liveagent.gateway.v1.AuthResponse\x1a\x03\x88\x02\x01B@Z>github.com/liveagent/agent-gateway/internal/proto/v1;gatewayv1b\x06proto3" + "\x1dTUNNEL_WS_MESSAGE_TYPE_BINARY\x10\x02B@Z>github.com/liveagent/agent-gateway/internal/proto/v1;gatewayv1b\x06proto3" var ( file_proto_v1_gateway_proto_rawDescOnce sync.Once @@ -12262,14 +12258,8 @@ var file_proto_v1_gateway_proto_depIdxs = []int32{ 111, // 153: liveagent.gateway.v1.FsRootsResponse.roots:type_name -> liveagent.gateway.v1.FsRoot 115, // 154: liveagent.gateway.v1.FsListDirsResponse.entries:type_name -> liveagent.gateway.v1.FsDirEntry 120, // 155: liveagent.gateway.v1.FsListResponse.entries:type_name -> liveagent.gateway.v1.FsListEntry - 6, // 156: liveagent.gateway.v1.AgentGateway.AgentConnect:input_type -> liveagent.gateway.v1.AgentEnvelope - 48, // 157: liveagent.gateway.v1.AgentGateway.AgentTerminalConnect:input_type -> liveagent.gateway.v1.TerminalStreamFrame - 3, // 158: liveagent.gateway.v1.AgentGateway.Authenticate:input_type -> liveagent.gateway.v1.AuthRequest - 5, // 159: liveagent.gateway.v1.AgentGateway.AgentConnect:output_type -> liveagent.gateway.v1.GatewayEnvelope - 48, // 160: liveagent.gateway.v1.AgentGateway.AgentTerminalConnect:output_type -> liveagent.gateway.v1.TerminalStreamFrame - 4, // 161: liveagent.gateway.v1.AgentGateway.Authenticate:output_type -> liveagent.gateway.v1.AuthResponse - 159, // [159:162] is the sub-list for method output_type - 156, // [156:159] is the sub-list for method input_type + 156, // [156:156] is the sub-list for method output_type + 156, // [156:156] is the sub-list for method input_type 156, // [156:156] is the sub-list for extension type_name 156, // [156:156] is the sub-list for extension extendee 0, // [0:156] is the sub-list for field type_name @@ -12400,7 +12390,7 @@ func file_proto_v1_gateway_proto_init() { NumEnums: 3, NumMessages: 136, NumExtensions: 0, - NumServices: 1, + NumServices: 0, }, GoTypes: file_proto_v1_gateway_proto_goTypes, DependencyIndexes: file_proto_v1_gateway_proto_depIdxs, diff --git a/crates/agent-gateway/internal/proto/v1/gateway_grpc.pb.go b/crates/agent-gateway/internal/proto/v1/gateway_grpc.pb.go deleted file mode 100644 index 5a7e6d446..000000000 --- a/crates/agent-gateway/internal/proto/v1/gateway_grpc.pb.go +++ /dev/null @@ -1,200 +0,0 @@ -// Code generated by protoc-gen-go-grpc. DO NOT EDIT. -// versions: -// - protoc-gen-go-grpc v1.6.2 -// - protoc (unknown) -// source: proto/v1/gateway.proto - -package gatewayv1 - -import ( - context "context" - grpc "google.golang.org/grpc" - codes "google.golang.org/grpc/codes" - status "google.golang.org/grpc/status" -) - -// This is a compile-time assertion to ensure that this generated file -// is compatible with the grpc package it is being compiled against. -// Requires gRPC-Go v1.64.0 or later. -const _ = grpc.SupportPackageIsVersion9 - -const ( - AgentGateway_AgentConnect_FullMethodName = "/liveagent.gateway.v1.AgentGateway/AgentConnect" - AgentGateway_AgentTerminalConnect_FullMethodName = "/liveagent.gateway.v1.AgentGateway/AgentTerminalConnect" - AgentGateway_Authenticate_FullMethodName = "/liveagent.gateway.v1.AgentGateway/Authenticate" -) - -// AgentGatewayClient is the client API for AgentGateway service. -// -// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. -// -// AgentGateway 是 v1 的桌面端 gRPC 服务。已弃用:v2 起由 /ws/v2/agent 与 /ws/v2/terminal -// (见 proto/v2/gateway_ws.proto)承担,仅为旧版桌面客户端保留,流量归零后删除; -// 本文件的消息是 v2 的业务载荷,全部保留、永不弃用。 -// -// Deprecated: Do not use. -type AgentGatewayClient interface { - AgentConnect(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[AgentEnvelope, GatewayEnvelope], error) - AgentTerminalConnect(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[TerminalStreamFrame, TerminalStreamFrame], error) - Authenticate(ctx context.Context, in *AuthRequest, opts ...grpc.CallOption) (*AuthResponse, error) -} - -type agentGatewayClient struct { - cc grpc.ClientConnInterface -} - -// Deprecated: Do not use. -func NewAgentGatewayClient(cc grpc.ClientConnInterface) AgentGatewayClient { - return &agentGatewayClient{cc} -} - -func (c *agentGatewayClient) AgentConnect(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[AgentEnvelope, GatewayEnvelope], error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - stream, err := c.cc.NewStream(ctx, &AgentGateway_ServiceDesc.Streams[0], AgentGateway_AgentConnect_FullMethodName, cOpts...) - if err != nil { - return nil, err - } - x := &grpc.GenericClientStream[AgentEnvelope, GatewayEnvelope]{ClientStream: stream} - return x, nil -} - -// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. -type AgentGateway_AgentConnectClient = grpc.BidiStreamingClient[AgentEnvelope, GatewayEnvelope] - -func (c *agentGatewayClient) AgentTerminalConnect(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[TerminalStreamFrame, TerminalStreamFrame], error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - stream, err := c.cc.NewStream(ctx, &AgentGateway_ServiceDesc.Streams[1], AgentGateway_AgentTerminalConnect_FullMethodName, cOpts...) - if err != nil { - return nil, err - } - x := &grpc.GenericClientStream[TerminalStreamFrame, TerminalStreamFrame]{ClientStream: stream} - return x, nil -} - -// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. -type AgentGateway_AgentTerminalConnectClient = grpc.BidiStreamingClient[TerminalStreamFrame, TerminalStreamFrame] - -func (c *agentGatewayClient) Authenticate(ctx context.Context, in *AuthRequest, opts ...grpc.CallOption) (*AuthResponse, error) { - cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(AuthResponse) - err := c.cc.Invoke(ctx, AgentGateway_Authenticate_FullMethodName, in, out, cOpts...) - if err != nil { - return nil, err - } - return out, nil -} - -// AgentGatewayServer is the server API for AgentGateway service. -// All implementations must embed UnimplementedAgentGatewayServer -// for forward compatibility. -// -// AgentGateway 是 v1 的桌面端 gRPC 服务。已弃用:v2 起由 /ws/v2/agent 与 /ws/v2/terminal -// (见 proto/v2/gateway_ws.proto)承担,仅为旧版桌面客户端保留,流量归零后删除; -// 本文件的消息是 v2 的业务载荷,全部保留、永不弃用。 -// -// Deprecated: Do not use. -type AgentGatewayServer interface { - AgentConnect(grpc.BidiStreamingServer[AgentEnvelope, GatewayEnvelope]) error - AgentTerminalConnect(grpc.BidiStreamingServer[TerminalStreamFrame, TerminalStreamFrame]) error - Authenticate(context.Context, *AuthRequest) (*AuthResponse, error) - mustEmbedUnimplementedAgentGatewayServer() -} - -// UnimplementedAgentGatewayServer must be embedded to have -// forward compatible implementations. -// -// NOTE: this should be embedded by value instead of pointer to avoid a nil -// pointer dereference when methods are called. -type UnimplementedAgentGatewayServer struct{} - -func (UnimplementedAgentGatewayServer) AgentConnect(grpc.BidiStreamingServer[AgentEnvelope, GatewayEnvelope]) error { - return status.Error(codes.Unimplemented, "method AgentConnect not implemented") -} -func (UnimplementedAgentGatewayServer) AgentTerminalConnect(grpc.BidiStreamingServer[TerminalStreamFrame, TerminalStreamFrame]) error { - return status.Error(codes.Unimplemented, "method AgentTerminalConnect not implemented") -} -func (UnimplementedAgentGatewayServer) Authenticate(context.Context, *AuthRequest) (*AuthResponse, error) { - return nil, status.Error(codes.Unimplemented, "method Authenticate not implemented") -} -func (UnimplementedAgentGatewayServer) mustEmbedUnimplementedAgentGatewayServer() {} -func (UnimplementedAgentGatewayServer) testEmbeddedByValue() {} - -// UnsafeAgentGatewayServer may be embedded to opt out of forward compatibility for this service. -// Use of this interface is not recommended, as added methods to AgentGatewayServer will -// result in compilation errors. -type UnsafeAgentGatewayServer interface { - mustEmbedUnimplementedAgentGatewayServer() -} - -// Deprecated: Do not use. -func RegisterAgentGatewayServer(s grpc.ServiceRegistrar, srv AgentGatewayServer) { - // If the following call panics, it indicates UnimplementedAgentGatewayServer was - // embedded by pointer and is nil. This will cause panics if an - // unimplemented method is ever invoked, so we test this at initialization - // time to prevent it from happening at runtime later due to I/O. - if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { - t.testEmbeddedByValue() - } - s.RegisterService(&AgentGateway_ServiceDesc, srv) -} - -func _AgentGateway_AgentConnect_Handler(srv interface{}, stream grpc.ServerStream) error { - return srv.(AgentGatewayServer).AgentConnect(&grpc.GenericServerStream[AgentEnvelope, GatewayEnvelope]{ServerStream: stream}) -} - -// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. -type AgentGateway_AgentConnectServer = grpc.BidiStreamingServer[AgentEnvelope, GatewayEnvelope] - -func _AgentGateway_AgentTerminalConnect_Handler(srv interface{}, stream grpc.ServerStream) error { - return srv.(AgentGatewayServer).AgentTerminalConnect(&grpc.GenericServerStream[TerminalStreamFrame, TerminalStreamFrame]{ServerStream: stream}) -} - -// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. -type AgentGateway_AgentTerminalConnectServer = grpc.BidiStreamingServer[TerminalStreamFrame, TerminalStreamFrame] - -func _AgentGateway_Authenticate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { - in := new(AuthRequest) - if err := dec(in); err != nil { - return nil, err - } - if interceptor == nil { - return srv.(AgentGatewayServer).Authenticate(ctx, in) - } - info := &grpc.UnaryServerInfo{ - Server: srv, - FullMethod: AgentGateway_Authenticate_FullMethodName, - } - handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(AgentGatewayServer).Authenticate(ctx, req.(*AuthRequest)) - } - return interceptor(ctx, in, info, handler) -} - -// AgentGateway_ServiceDesc is the grpc.ServiceDesc for AgentGateway service. -// It's only intended for direct use with grpc.RegisterService, -// and not to be introspected or modified (even as a copy) -var AgentGateway_ServiceDesc = grpc.ServiceDesc{ - ServiceName: "liveagent.gateway.v1.AgentGateway", - HandlerType: (*AgentGatewayServer)(nil), - Methods: []grpc.MethodDesc{ - { - MethodName: "Authenticate", - Handler: _AgentGateway_Authenticate_Handler, - }, - }, - Streams: []grpc.StreamDesc{ - { - StreamName: "AgentConnect", - Handler: _AgentGateway_AgentConnect_Handler, - ServerStreams: true, - ClientStreams: true, - }, - { - StreamName: "AgentTerminalConnect", - Handler: _AgentGateway_AgentTerminalConnect_Handler, - ServerStreams: true, - ClientStreams: true, - }, - }, - Metadata: "proto/v1/gateway.proto", -} diff --git a/crates/agent-gateway/internal/protocol/pbws/agent_conn.go b/crates/agent-gateway/internal/protocol/pbws/agent_conn.go index 1c7ea7c71..d5dc32294 100644 --- a/crates/agent-gateway/internal/protocol/pbws/agent_conn.go +++ b/crates/agent-gateway/internal/protocol/pbws/agent_conn.go @@ -15,8 +15,8 @@ import ( "github.com/liveagent/agent-gateway/internal/session" ) -// AgentHandler 返回 /ws/v2/agent 的 HTTP 处理器,替代 v1 gRPC Authenticate+AgentConnect: -// hello 一并完成鉴权与会话登记,之后的双向信封流语义与 grpc.go 逐一对应。 +// AgentHandler 返回 /ws/v2/agent 的 HTTP 处理器(承接 v1 gRPC Authenticate+AgentConnect 职能): +// hello 一并完成鉴权与会话登记,之后进入双向信封流。 func (s *Server) AgentHandler() http.Handler { upgrader := s.upgrader() return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -187,7 +187,7 @@ func (s *Server) writeAgentEnvelope(conn *websocket.Conn, env *gatewayv1.Gateway return conn.WriteMessage(websocket.BinaryMessage, data) == nil } -// agentHeartbeatLoop 与 grpc.go 的 heartbeatLoop 语义一致:周期发应用层 Ping(走专用心跳通道)、 +// agentHeartbeatLoop:周期发应用层 Ping(走专用心跳通道)、 // 驱逐心跳过期会话;额外补发 WS 控制帧 ping,由 tokio-tungstenite 自动 pong 承担传输层保活。 func (s *Server) agentHeartbeatLoop(ctx context.Context, conn *websocket.Conn, sess *session.AgentSession) { period := 30 * time.Second diff --git a/crates/agent-gateway/internal/protocol/pbws/guard.go b/crates/agent-gateway/internal/protocol/pbws/guard.go index f9f0be621..30eca9cfd 100644 --- a/crates/agent-gateway/internal/protocol/pbws/guard.go +++ b/crates/agent-gateway/internal/protocol/pbws/guard.go @@ -61,11 +61,16 @@ func vetAgentRequest(sm *session.Manager, env *gatewayv1.GatewayEnvelope) error *gatewayv1.GatewayEnvelope_FsDelete, *gatewayv1.GatewayEnvelope_FsReadEditableText, *gatewayv1.GatewayEnvelope_FsReadWorkspaceImage, - *gatewayv1.GatewayEnvelope_GitRequest, *gatewayv1.GatewayEnvelope_ChatQueue: return nil // ---- 带功能门控 / 限额的直通臂 ---- + case *gatewayv1.GatewayEnvelope_GitRequest: + action := strings.TrimSpace(payload.GitRequest.GetAction()) + if gitActionIsWrite(action) && !sm.WebGitEnabled() { + return errors.New("web git is disabled in desktop Remote settings") + } + return nil case *gatewayv1.GatewayEnvelope_TerminalRequest: req := payload.TerminalRequest action := strings.TrimSpace(req.GetAction()) @@ -99,6 +104,17 @@ func vetAgentRequest(sm *session.Manager, env *gatewayv1.GatewayEnvelope) error } } +// gitActionIsWrite 判定 git 直通请求是否为写操作:写操作受桌面端 Remote 设置 +// enable_web_git 门控,读操作(status/log/diff 等)始终放行。 +func gitActionIsWrite(action string) bool { + switch action { + case "init", "switch_branch", "create_branch", "stage", "stage_all", "unstage", "unstage_all", "discard", "discard_all", "add_to_gitignore", "commit", "fetch", "pull", "set_remote", "push", "delete_branch", "rename_branch", "stash_push", "stash_pop": + return true + default: + return false + } +} + // clampHistoryList 施加与 v1 相同的分页默认值与上限。 func clampHistoryList(req *gatewayv1.HistoryListRequest) { if req == nil { diff --git a/crates/agent-gateway/internal/protocol/pbws/server.go b/crates/agent-gateway/internal/protocol/pbws/server.go index 01a7f86c8..813291dfb 100644 --- a/crates/agent-gateway/internal/protocol/pbws/server.go +++ b/crates/agent-gateway/internal/protocol/pbws/server.go @@ -1,5 +1,5 @@ // Package pbws 实现 v2 统一线协议(WebSocket+Protobuf)服务端的三条链路(见 proto/v2/gateway_ws.proto): -// /ws/v2 浏览器直通、/ws/v2/agent 桌面端(替代 v1 gRPC)、/ws/v2/terminal 终端数据面。 +// /ws/v2 浏览器直通、/ws/v2/agent 桌面端信封流、/ws/v2/terminal 终端数据面。 // 本包只做帧编解码、鉴权握手、直通白名单与事件扇出;会话状态复用 session, // 传输运行时复用 wscore,跨协议域逻辑复用 shared 与 chatcmd。 package pbws @@ -47,7 +47,7 @@ func (s *Server) upgrader() websocket.Upgrader { } } -// readLimit 与 v1 保持一致:复用 gRPC 消息大小上限。 +// readLimit 复用 GRPCMaxMessageBytes 配置(历史命名保留,语义为消息大小上限)。 func (s *Server) readLimit() int64 { if s.cfg != nil && s.cfg.GRPCMaxMessageBytes > 0 { return int64(s.cfg.GRPCMaxMessageBytes) diff --git a/crates/agent-gateway/internal/server/grpc.go b/crates/agent-gateway/internal/server/grpc.go deleted file mode 100644 index 38a67fc5e..000000000 --- a/crates/agent-gateway/internal/server/grpc.go +++ /dev/null @@ -1,289 +0,0 @@ -package server - -import ( - "context" - "errors" - "io" - "log/slog" - "strings" - "time" - - "github.com/google/uuid" - - "github.com/liveagent/agent-gateway/internal/config" - "github.com/liveagent/agent-gateway/internal/observability" - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" - "github.com/liveagent/agent-gateway/internal/session" -) - -// GRPCServer implements the v1 AgentGateway gRPC service for the desktop -// agent link. -// -// Deprecated: v1 gRPC 链路已被 v2 /ws/v2/agent(WebSocket+Protobuf,internal/protocol/pbws)取代,仅为旧版桌面客户端保留;流量归零后连同 gRPC 监听与拦截器一并删除。 -type GRPCServer struct { - gatewayv1.UnimplementedAgentGatewayServer - - cfg *config.Config - sm *session.Manager -} - -// NewGRPCServer constructs the v1 gRPC service implementation. -// -// Deprecated: 见 GRPCServer。 -func NewGRPCServer(cfg *config.Config, sm *session.Manager) *GRPCServer { - return &GRPCServer{ - cfg: cfg, - sm: sm, - } -} - -func (s *GRPCServer) Authenticate(_ context.Context, req *gatewayv1.AuthRequest) (*gatewayv1.AuthResponse, error) { - expectedToken := strings.TrimSpace(s.cfg.Token) - if expectedToken == "" || strings.TrimSpace(req.GetToken()) != expectedToken { - return &gatewayv1.AuthResponse{ - Success: false, - Message: "invalid token", - }, nil - } - - sessionID := uuid.NewString() - s.sm.RecordAuthentication(req.GetAgentId(), req.GetAgentVersion(), sessionID) - - return &gatewayv1.AuthResponse{ - Success: true, - Message: "ok", - SessionId: sessionID, - }, nil -} - -func (s *GRPCServer) AgentConnect(stream gatewayv1.AgentGateway_AgentConnectServer) error { - // v1 使用打点:gRPC agent 链路已被 /ws/v2/agent 取代,观察归零后删除。 - observability.Usage.V1GRPCAgentConnectsTotal.Add(1) - observability.Usage.V1GRPCAgentActive.Add(1) - defer observability.Usage.V1GRPCAgentActive.Add(-1) - slog.Warn("deprecated v1 gRPC agent stream established") - - authSnapshot := s.sm.LatestAuthSnapshot() - sess := session.NewAgentSession(authSnapshot) - toAgent := sess.Outbound() - s.sm.SetSession(sess) - defer s.sm.ClearSession(sess) - - ctx, cancel := context.WithCancel(stream.Context()) - defer cancel() - - go s.heartbeatLoop(ctx, sess) - go func() { - select { - case <-ctx.Done(): - case <-sess.Done(): - cancel() - } - }() - - pings := sess.Pings() - sendErrCh := make(chan error, 1) - go func() { - for { - // Heartbeats jump the shared data queue so congestion can never - // starve them. - select { - case ping := <-pings: - if err := stream.Send(ping); err != nil { - sendErrCh <- err - cancel() - return - } - continue - default: - } - select { - case <-ctx.Done(): - sendErrCh <- ctx.Err() - return - case <-sess.Done(): - sendErrCh <- nil - cancel() - return - case ping := <-pings: - if err := stream.Send(ping); err != nil { - sendErrCh <- err - cancel() - return - } - case outbound := <-toAgent: - if outbound == nil || outbound.GatewayEnvelope == nil { - continue - } - select { - case <-outbound.Context().Done(): - outbound.Ack(outbound.Context().Err()) - continue - default: - } - if err := stream.Send(outbound.GatewayEnvelope); err != nil { - outbound.Ack(err) - sendErrCh <- err - cancel() - return - } - outbound.Ack(nil) - } - } - }() - - for { - select { - case <-ctx.Done(): - return ctx.Err() - case err := <-sendErrCh: - if err == nil || err == context.Canceled { - return nil - } - return err - default: - } - - env, err := stream.Recv() - if err != nil { - if err == io.EOF { - return nil - } - return err - } - - // Any inbound envelope proves the agent is alive; a streaming agent - // must never be declared heartbeat-stale. - s.sm.TouchHeartbeat(sess) - // Pongs flow through the same dispatch as every other envelope: - // correlated probes registered a request stream before sending their - // Ping and match by request_id, while periodic heartbeat Pongs have - // no registered stream and are harmlessly ignored there. - s.sm.DispatchFromAgentForSession(sess, env) - } -} - -func (s *GRPCServer) AgentTerminalConnect(stream gatewayv1.AgentGateway_AgentTerminalConnectServer) error { - observability.Usage.V1GRPCTerminalConnectsTotal.Add(1) - slog.Warn("deprecated v1 gRPC terminal stream established") - - toAgent := make(chan *gatewayv1.TerminalStreamFrame, 4096) - cleanup := s.sm.RegisterTerminalStreamToAgent(toAgent) - defer cleanup() - - ctx, cancel := context.WithCancel(stream.Context()) - defer cancel() - - if err := stream.Send(gatewayTerminalStreamReadyFrame()); err != nil { - return err - } - - sendErrCh := make(chan error, 1) - recvErrCh := make(chan error, 1) - go func() { - for { - select { - case <-ctx.Done(): - return - case frame := <-toAgent: - if frame == nil { - continue - } - if err := stream.Send(frame); err != nil { - sendErrCh <- err - cancel() - return - } - } - } - }() - - go func() { - frame, err := stream.Recv() - for err == nil { - s.sm.BroadcastTerminalStreamFrame(frame) - frame, err = stream.Recv() - } - if err == io.EOF { - recvErrCh <- nil - } else { - recvErrCh <- err - } - cancel() - }() - - for { - select { - case <-ctx.Done(): - if errors.Is(ctx.Err(), context.Canceled) { - return nil - } - return ctx.Err() - case err := <-sendErrCh: - cancel() - if err == nil || errors.Is(err, context.Canceled) { - return nil - } - return err - case err := <-recvErrCh: - cancel() - if err == nil || errors.Is(err, context.Canceled) { - return nil - } - return err - } - } -} - -func gatewayTerminalStreamReadyFrame() *gatewayv1.TerminalStreamFrame { - return &gatewayv1.TerminalStreamFrame{ - Kind: "detach", - StreamId: "gateway-ready-" + uuid.NewString(), - } -} - -func (s *GRPCServer) heartbeatLoop(ctx context.Context, sess *session.AgentSession) { - period := s.heartbeatPeriod() - ticker := time.NewTicker(period) - defer ticker.Stop() - - if !s.sendHeartbeat(sess) { - return - } - - timeout := period * 3 - for { - select { - case <-ctx.Done(): - return - case <-sess.Done(): - return - case <-ticker.C: - if s.sm.ClearSessionIfHeartbeatStale(sess, timeout) { - return - } - if !s.sendHeartbeat(sess) { - return - } - } - } -} - -func (s *GRPCServer) heartbeatPeriod() time.Duration { - if s.cfg == nil || s.cfg.HeartbeatPeriod <= 0 { - return 30 * time.Second - } - return s.cfg.HeartbeatPeriod -} - -func (s *GRPCServer) sendHeartbeat(sess *session.AgentSession) bool { - return sess.SendPing(&gatewayv1.GatewayEnvelope{ - RequestId: "ping-" + uuid.NewString(), - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_Ping{ - Ping: &gatewayv1.PingRequest{ - Timestamp: time.Now().Unix(), - }, - }, - }) == nil -} diff --git a/crates/agent-gateway/internal/server/grpc_test.go b/crates/agent-gateway/internal/server/grpc_test.go deleted file mode 100644 index 09d53b8f3..000000000 --- a/crates/agent-gateway/internal/server/grpc_test.go +++ /dev/null @@ -1,219 +0,0 @@ -package server - -import ( - "context" - "net" - "testing" - "time" - - "github.com/liveagent/agent-gateway/internal/config" - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" - "github.com/liveagent/agent-gateway/internal/session" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" - "google.golang.org/grpc/test/bufconn" -) - -func TestAgentTerminalConnectSendsReadyFrame(t *testing.T) { - listener := bufconn.Listen(1024 * 1024) - grpcServer := grpc.NewServer() - gatewayv1.RegisterAgentGatewayServer(grpcServer, NewGRPCServer(&config.Config{}, session.NewManager())) - t.Cleanup(func() { - grpcServer.Stop() - _ = listener.Close() - }) - - serveErr := make(chan error, 1) - go func() { - serveErr <- grpcServer.Serve(listener) - }() - - conn, err := grpc.NewClient( - "passthrough:///bufnet", - grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { - return listener.DialContext(ctx) - }), - grpc.WithTransportCredentials(insecure.NewCredentials()), - ) - if err != nil { - t.Fatalf("dial bufconn gRPC: %v", err) - } - t.Cleanup(func() { - _ = conn.Close() - }) - - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - - stream, err := gatewayv1.NewAgentGatewayClient(conn).AgentTerminalConnect(ctx) - if err != nil { - t.Fatalf("open terminal stream: %v", err) - } - frame, err := stream.Recv() - if err != nil { - t.Fatalf("receive terminal ready frame: %v", err) - } - if frame.GetKind() != "detach" { - t.Fatalf("ready frame kind = %q, want detach", frame.GetKind()) - } - if streamID := frame.GetStreamId(); len(streamID) < len("gateway-ready-") || streamID[:len("gateway-ready-")] != "gateway-ready-" { - t.Fatalf("ready frame stream id = %q, want gateway-ready-*", streamID) - } - - grpcServer.Stop() - select { - case err := <-serveErr: - if err != nil && err != grpc.ErrServerStopped { - t.Fatalf("Serve returned error: %v", err) - } - case <-time.After(2 * time.Second): - t.Fatal("gRPC server did not stop") - } -} - -func TestAgentConnectTouchesHeartbeatOnAnyEnvelope(t *testing.T) { - listener := bufconn.Listen(1024 * 1024) - sm := session.NewManager() - grpcServer := grpc.NewServer() - gatewayv1.RegisterAgentGatewayServer( - grpcServer, - NewGRPCServer(&config.Config{HeartbeatPeriod: 100 * time.Millisecond}, sm), - ) - t.Cleanup(func() { - grpcServer.Stop() - _ = listener.Close() - }) - go func() { - _ = grpcServer.Serve(listener) - }() - - conn, err := grpc.NewClient( - "passthrough:///bufnet", - grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { - return listener.DialContext(ctx) - }), - grpc.WithTransportCredentials(insecure.NewCredentials()), - ) - if err != nil { - t.Fatalf("dial bufconn gRPC: %v", err) - } - t.Cleanup(func() { - _ = conn.Close() - }) - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - stream, err := gatewayv1.NewAgentGatewayClient(conn).AgentConnect(ctx) - if err != nil { - t.Fatalf("open agent stream: %v", err) - } - - // The dedicated lane must deliver pings regardless of data-queue state. - if _, err := stream.Recv(); err != nil { - t.Fatalf("receive initial ping: %v", err) - } - - // Never send a Pong: non-pong traffic alone must keep the session alive - // through several staleness windows (timeout = 3 x 100ms period). - deadline := time.Now().Add(700 * time.Millisecond) - for time.Now().Before(deadline) { - if err := stream.Send(&gatewayv1.AgentEnvelope{RequestId: "activity"}); err != nil { - t.Fatalf("send activity envelope: %v", err) - } - if !sm.IsOnline() { - t.Fatalf("session cleared while agent was actively sending") - } - time.Sleep(50 * time.Millisecond) - } - - // Once traffic stops, the staleness sweep must still clear the session. - waitUntil := time.Now().Add(2 * time.Second) - for sm.IsOnline() { - if time.Now().After(waitUntil) { - t.Fatalf("session not cleared after inbound traffic stopped") - } - time.Sleep(25 * time.Millisecond) - } -} - -func TestAgentConnectDispatchesCorrelatedPong(t *testing.T) { - t.Parallel() - - listener := bufconn.Listen(1024 * 1024) - sm := session.NewManager() - grpcServer := grpc.NewServer() - gatewayv1.RegisterAgentGatewayServer( - grpcServer, - NewGRPCServer(&config.Config{HeartbeatPeriod: time.Hour}, sm), - ) - t.Cleanup(func() { - grpcServer.Stop() - _ = listener.Close() - }) - go func() { _ = grpcServer.Serve(listener) }() - - conn, err := grpc.NewClient( - "passthrough:///bufnet", - grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { - return listener.DialContext(ctx) - }), - grpc.WithTransportCredentials(insecure.NewCredentials()), - ) - if err != nil { - t.Fatalf("dial bufconn gRPC: %v", err) - } - t.Cleanup(func() { _ = conn.Close() }) - - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - stream, err := gatewayv1.NewAgentGatewayClient(conn).AgentConnect(ctx) - if err != nil { - t.Fatalf("open agent stream: %v", err) - } - if _, err := stream.Recv(); err != nil { - t.Fatalf("receive initial heartbeat: %v", err) - } - - const requestID = "chat-runtime-wake-correlated" - // RegisterStreamAndSendContext 在 gRPC 泵 Ack 送达后返回;服务端 AgentConnect 泵已在运行,可内联调用。 - responses, done, cleanup, err := sm.RegisterStreamAndSendContext(ctx, requestID, &gatewayv1.GatewayEnvelope{ - RequestId: requestID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_Ping{ - Ping: &gatewayv1.PingRequest{Timestamp: time.Now().Unix()}, - }, - }) - if err != nil { - t.Fatalf("register correlated response stream: %v", err) - } - defer cleanup() - - probe, err := stream.Recv() - if err != nil { - t.Fatalf("receive correlated probe: %v", err) - } - if probe.GetRequestId() != requestID || probe.GetPing() == nil { - t.Fatalf("correlated probe = %#v", probe) - } - if err := stream.Send(&gatewayv1.AgentEnvelope{ - RequestId: requestID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.AgentEnvelope_Pong{ - Pong: &gatewayv1.PongResponse{Timestamp: probe.GetPing().GetTimestamp()}, - }, - }); err != nil { - t.Fatalf("send correlated pong: %v", err) - } - - select { - case response := <-responses: - if response.GetRequestId() != requestID || response.GetPong() == nil { - t.Fatalf("correlated response = %#v", response) - } - case <-done: - t.Fatal("correlated response stream closed before Pong dispatch") - case <-ctx.Done(): - t.Fatalf("timed out waiting for correlated Pong: %v", ctx.Err()) - } -} diff --git a/crates/agent-gateway/internal/server/http.go b/crates/agent-gateway/internal/server/http.go index ed7533299..10bd31e9e 100644 --- a/crates/agent-gateway/internal/server/http.go +++ b/crates/agent-gateway/internal/server/http.go @@ -23,24 +23,22 @@ import ( func NewHTTPServer(cfg *config.Config, sm *session.Manager) http.Handler { rootMux := http.NewServeMux() - webSocketServer := NewWebSocketServer(cfg, sm) - terminalWebSocketServer := NewTerminalWebSocketServer(cfg, sm) rootMux.HandleFunc("GET /healthz", handler.Health()) - rootMux.Handle("/ws", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if isTerminalWebSocketFallback(r) { - terminalWebSocketServer.ServeHTTP(w, r) - return - } - webSocketServer.ServeHTTP(w, r) - })) - rootMux.Handle("/ws/terminal", terminalWebSocketServer) - // v2 统一协议(WebSocket+Protobuf)三链路;v1 路由保留服务旧客户端。 + // v2 统一协议(WebSocket+Protobuf)三链路。 v2 := pbws.NewServer(cfg, sm) rootMux.Handle("/ws/v2", v2.BrowserHandler()) rootMux.Handle("/ws/v2/agent", v2.AgentHandler()) rootMux.Handle("/ws/v2/terminal", v2.TerminalHandler()) + // v1 路由(JSON 信封 /ws、二进制终端流 /ws/terminal)已移除:显式回 410, + // 让未刷新的旧客户端得到可诊断的拒绝,而不是落进 SPA fallback 拿到 index.html。 + goneV1 := func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "v1 websocket protocol removed; upgrade to /ws/v2", http.StatusGone) + } + rootMux.HandleFunc("/ws", goneV1) + rootMux.HandleFunc("/ws/terminal", goneV1) + rootMux.HandleFunc("/t/", publicTunnelProxy(sm)) rootMux.HandleFunc("GET /image-proxy", handler.ImageProxy(cfg.RequestTimeout)) rootMux.HandleFunc("GET /api/public/history-shares/{token}", publicHistoryShare(cfg, sm)) @@ -96,11 +94,6 @@ func NewHTTPServer(cfg *config.Config, sm *session.Manager) http.Handler { return rootMux } -func isTerminalWebSocketFallback(r *http.Request) bool { - value := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("terminal"))) - return value == "1" || value == "true" || value == "stream" -} - func isWebUIStaticAssetPath(cleanPath string) bool { cleanPath = strings.TrimSpace(cleanPath) if cleanPath == "" || cleanPath == "." || cleanPath == "index.html" { @@ -125,7 +118,7 @@ func publicHistoryShare(cfg *config.Config, sm *session.Manager) http.HandlerFun defer cancel() requestID := "public-history-share-" + uuid.NewString() - response, err := awaitAgentUnaryResponse(ctx, sm, requestID, &gatewayv1.GatewayEnvelope{ + response, err := sm.AwaitUnaryResponse(ctx, requestID, &gatewayv1.GatewayEnvelope{ RequestId: requestID, Timestamp: time.Now().Unix(), Payload: &gatewayv1.GatewayEnvelope_HistoryShareResolve{ @@ -160,7 +153,7 @@ func publicHistoryShare(cfg *config.Config, sm *session.Manager) http.HandlerFun "conversation_id": share.GetConversationId(), "messages_json": share.GetMessagesJson(), "total_message_count": share.GetTotalMessageCount(), - "conversation": websocketConversationSummaryPayload(share.GetConversation()), + "conversation": conversationSummaryPayload(share.GetConversation()), "redact_tool_content": share.GetRedactToolContent(), }) } diff --git a/crates/agent-gateway/internal/server/http_origin.go b/crates/agent-gateway/internal/server/http_origin.go deleted file mode 100644 index 7452353f2..000000000 --- a/crates/agent-gateway/internal/server/http_origin.go +++ /dev/null @@ -1,13 +0,0 @@ -package server - -import ( - "net/http" - - "github.com/liveagent/agent-gateway/internal/protocol/shared" -) - -// originAllowed delegates to the shared origin check (moved to -// internal/protocol/shared so the v2 protocol endpoints reuse it). -func originAllowed(r *http.Request) bool { - return shared.OriginAllowed(r) -} diff --git a/crates/agent-gateway/internal/server/http_test.go b/crates/agent-gateway/internal/server/http_test.go index b52e874e3..970b8bd3c 100644 --- a/crates/agent-gateway/internal/server/http_test.go +++ b/crates/agent-gateway/internal/server/http_test.go @@ -11,6 +11,7 @@ import ( "github.com/gorilla/websocket" "github.com/liveagent/agent-gateway/internal/config" gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" + "github.com/liveagent/agent-gateway/internal/protocol/shared" "github.com/liveagent/agent-gateway/internal/session" ) @@ -81,7 +82,7 @@ func TestWebSocketRejectsForeignOrigin(t *testing.T) { ts := httptest.NewServer(NewHTTPServer(&config.Config{Token: "dev-token"}, session.NewManager())) defer ts.Close() - wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws" + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws/v2" conn, resp, err := websocket.DefaultDialer.Dial(wsURL, http.Header{ "Origin": []string{"https://evil.example"}, }) @@ -97,53 +98,6 @@ func TestWebSocketRejectsForeignOrigin(t *testing.T) { } } -func TestNewHTTPServerRoutesTerminalStreamWebSocket(t *testing.T) { - ts := httptest.NewServer(NewHTTPServer(&config.Config{Token: "dev-token"}, session.NewManager())) - defer ts.Close() - - wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws/terminal" - assertTerminalStreamWebSocketReady(t, wsURL, ts.URL) -} - -func TestNewHTTPServerRoutesTerminalStreamWebSocketFallback(t *testing.T) { - ts := httptest.NewServer(NewHTTPServer(&config.Config{Token: "dev-token"}, session.NewManager())) - defer ts.Close() - - wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws?terminal=1" - assertTerminalStreamWebSocketReady(t, wsURL, ts.URL) -} - -func assertTerminalStreamWebSocketReady(t *testing.T, wsURL string, origin string) { - t.Helper() - conn, resp, err := websocket.DefaultDialer.Dial(wsURL, http.Header{ - "Origin": []string{origin}, - }) - if err != nil { - if resp != nil { - t.Fatalf("terminal stream websocket status = %d, err = %v", resp.StatusCode, err) - } - t.Fatalf("dial terminal stream websocket: %v", err) - } - defer conn.Close() - - if err := conn.SetWriteDeadline(time.Now().Add(time.Second)); err != nil { - t.Fatalf("set terminal stream auth write deadline: %v", err) - } - if err := conn.WriteJSON(map[string]any{"type": "auth", "token": "dev-token"}); err != nil { - t.Fatalf("write terminal stream auth: %v", err) - } - if err := conn.SetReadDeadline(time.Now().Add(time.Second)); err != nil { - t.Fatalf("set terminal stream auth read deadline: %v", err) - } - var authResp map[string]any - if err := conn.ReadJSON(&authResp); err != nil { - t.Fatalf("read terminal stream auth response: %v", err) - } - if authResp["type"] != "ready" { - t.Fatalf("terminal stream auth response = %#v, want ready", authResp) - } -} - func TestPublicHistoryShareResolvesWithoutAuthorization(t *testing.T) { sm := session.NewManager() sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") @@ -306,7 +260,7 @@ func TestOriginAllowedRequiresStrictOriginForPublicHosts(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "http://gateway.test:8080/api/chat/commands", nil) req.Header.Set("Origin", "http://gateway.test:5173") - if originAllowed(req) { + if shared.OriginAllowed(req) { t.Fatal("expected same hostname with different public port to be rejected") } } @@ -315,7 +269,7 @@ func TestOriginAllowedPermitsLoopbackDevPorts(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "http://127.0.0.1:8080/api/chat/commands", nil) req.Header.Set("Origin", "http://localhost:5173") - if !originAllowed(req) { + if !shared.OriginAllowed(req) { t.Fatal("expected loopback development origins to be allowed across ports") } } @@ -326,7 +280,7 @@ func TestOriginAllowedUsesForwardedProtoForSameOrigin(t *testing.T) { req.Header.Set("X-Forwarded-Proto", "https") req.Header.Set("Origin", "https://gateway.test") - if !originAllowed(req) { + if !shared.OriginAllowed(req) { t.Fatal("expected forwarded https origin to be allowed") } } diff --git a/crates/agent-gateway/internal/server/proto_json.go b/crates/agent-gateway/internal/server/proto_json.go new file mode 100644 index 000000000..99fab8d43 --- /dev/null +++ b/crates/agent-gateway/internal/server/proto_json.go @@ -0,0 +1,106 @@ +// proto 消息 → JSON map 塑形,供 HTTP JSON 端点(public share)使用。 +// protojson 会把 int64/uint64 编成字符串、int32 编成 float64;这里按描述符 +// 递归矫正为原生数值,保持对外 JSON 形状与历史线格式一致(公开分享页合同)。 +package server + +import ( + "encoding/json" + "reflect" + "strconv" + + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" +) + +func protoJSONPayload(message proto.Message, useProtoNames bool) map[string]any { + if message == nil || (reflect.ValueOf(message).Kind() == reflect.Pointer && reflect.ValueOf(message).IsNil()) { + return nil + } + raw, err := protojson.MarshalOptions{ + UseProtoNames: useProtoNames, + EmitUnpopulated: true, + }.Marshal(message) + if err != nil { + return map[string]any{} + } + var payload map[string]any + if err := json.Unmarshal(raw, &payload); err != nil { + return map[string]any{} + } + coerceProtoJSONNumbers(payload, message.ProtoReflect().Descriptor(), useProtoNames) + return payload +} + +func coerceProtoJSONNumbers(payload map[string]any, descriptor protoreflect.MessageDescriptor, useProtoNames bool) { + if payload == nil || descriptor == nil { + return + } + fields := descriptor.Fields() + for i := 0; i < fields.Len(); i++ { + field := fields.Get(i) + key := field.JSONName() + if useProtoNames { + key = field.TextName() + } + value, ok := payload[key] + if !ok { + continue + } + payload[key] = coerceProtoJSONField(value, field, useProtoNames) + } +} + +func coerceProtoJSONField(value any, field protoreflect.FieldDescriptor, useProtoNames bool) any { + if field == nil || value == nil { + return value + } + if field.IsList() { + items, ok := value.([]any) + if !ok { + return value + } + for i, item := range items { + items[i] = coerceProtoJSONScalarOrMessage(item, field, useProtoNames) + } + return items + } + return coerceProtoJSONScalarOrMessage(value, field, useProtoNames) +} + +func coerceProtoJSONScalarOrMessage(value any, field protoreflect.FieldDescriptor, useProtoNames bool) any { + if field.Kind() == protoreflect.MessageKind || field.Kind() == protoreflect.GroupKind { + if nested, ok := value.(map[string]any); ok { + coerceProtoJSONNumbers(nested, field.Message(), useProtoNames) + } + return value + } + switch field.Kind() { + case protoreflect.Int32Kind, protoreflect.Sint32Kind, protoreflect.Sfixed32Kind: + if number, ok := value.(float64); ok { + return int32(number) + } + case protoreflect.Uint32Kind, protoreflect.Fixed32Kind: + if number, ok := value.(float64); ok { + return uint32(number) + } + case protoreflect.Int64Kind, protoreflect.Sint64Kind, protoreflect.Sfixed64Kind: + if text, ok := value.(string); ok { + if parsed, err := strconv.ParseInt(text, 10, 64); err == nil { + return parsed + } + } + case protoreflect.Uint64Kind, protoreflect.Fixed64Kind: + if text, ok := value.(string); ok { + if parsed, err := strconv.ParseUint(text, 10, 64); err == nil { + return parsed + } + } + } + return value +} + +func conversationSummaryPayload(conversation *gatewayv1.ConversationSummary) map[string]any { + return protoJSONPayload(conversation, true) +} diff --git a/crates/agent-gateway/internal/server/proto_json_test.go b/crates/agent-gateway/internal/server/proto_json_test.go new file mode 100644 index 000000000..fc6633e5b --- /dev/null +++ b/crates/agent-gateway/internal/server/proto_json_test.go @@ -0,0 +1,40 @@ +package server + +import ( + "testing" + + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" +) + +// 公开分享页 JSON 合同:protojson 会把 int64 编成字符串、int32 编成 float64, +// coerce 链必须矫正为原生数值(前端时间戳/计数渲染依赖)。 +func TestProtoJSONPayloadPreservesFrontendNumberTypes(t *testing.T) { + payload := conversationSummaryPayload(&gatewayv1.ConversationSummary{ + Id: "conversation-1", + CreatedAt: 42, + UpdatedAt: 84, + MessageCount: 3, + }) + + if got := payload["created_at"]; got != int64(42) { + t.Fatalf("created_at = %#v (%T), want int64(42)", got, got) + } + if got := payload["updated_at"]; got != int64(84) { + t.Fatalf("updated_at = %#v (%T), want int64(84)", got, got) + } + if got := payload["message_count"]; got != int32(3) { + t.Fatalf("message_count = %#v (%T), want int32(3)", got, got) + } + if got := payload["id"]; got != "conversation-1" { + t.Fatalf("id = %#v, want conversation-1", got) + } +} + +func TestProtoJSONPayloadPreservesNilPayloads(t *testing.T) { + if payload := conversationSummaryPayload(nil); payload != nil { + t.Fatalf("conversation nil payload = %#v, want nil", payload) + } + if payload := protoJSONPayload(nil, true); payload != nil { + t.Fatalf("nil message payload = %#v, want nil", payload) + } +} diff --git a/crates/agent-gateway/internal/server/websocket.go b/crates/agent-gateway/internal/server/websocket.go deleted file mode 100644 index 93ba87c42..000000000 --- a/crates/agent-gateway/internal/server/websocket.go +++ /dev/null @@ -1,739 +0,0 @@ -package server - -import ( - "context" - "encoding/json" - "errors" - "io" - "log/slog" - "net/http" - "strings" - "sync" - "time" - - "github.com/gorilla/websocket" - - "github.com/liveagent/agent-gateway/internal/auth" - "github.com/liveagent/agent-gateway/internal/config" - "github.com/liveagent/agent-gateway/internal/observability" - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" - "github.com/liveagent/agent-gateway/internal/protocol/shared" - "github.com/liveagent/agent-gateway/internal/session" - "github.com/liveagent/agent-gateway/internal/transport/wscore" -) - -// websocketRequest is the inbound envelope of the v1 JSON protocol. -// -// Deprecated: v1 JSON 协议信封,请改用 v2(WebSocket+Protobuf,internal/protocol/pbws);v1 仅为旧客户端保留,流量归零后整体删除。 -type websocketRequest struct { - ID string `json:"id"` - Type string `json:"type"` - Payload json.RawMessage `json:"payload,omitempty"` -} - -// websocketEnvelope is the outbound envelope of the v1 JSON protocol. -// -// Deprecated: v1 JSON 协议信封,随 v1 一并移除(见 websocketRequest)。 -type websocketEnvelope struct { - ID string `json:"id,omitempty"` - Type string `json:"type"` - Payload any `json:"payload,omitempty"` - Error string `json:"error,omitempty"` - - // priority is transport-local metadata and is never serialized. It lets - // latency-sensitive acknowledgements bypass a congested data queue without - // promoting every potentially large response. - priority bool -} - -type websocketAuthPayload struct { - Token string `json:"token"` -} - -type websocketTerminalRequestPayload struct { - SessionID string `json:"session_id"` - ProjectPathKey string `json:"project_path_key"` - Cwd string `json:"cwd"` - Shell string `json:"shell"` - Title string `json:"title"` - Data string `json:"data"` - Cols *int `json:"cols"` - Rows *int `json:"rows"` - MaxBytes *int `json:"max_bytes"` - SshHostID string `json:"ssh_host_id"` - PromptID string `json:"prompt_id"` - PromptAnswer string `json:"prompt_answer"` - TrustHostKey bool `json:"trust_host_key"` - SftpEnabled bool `json:"sftp_enabled"` - TabID string `json:"tab_id"` - TabKind string `json:"tab_kind"` -} - -type websocketSshKnownHostResetPayload struct { - Host string `json:"host"` - Port *int `json:"port"` -} - -type websocketSftpRequestPayload struct { - SessionID string `json:"session_id"` - SessionIDCamel string `json:"sessionId"` - ProjectPathKey string `json:"project_path_key"` - ProjectPathKeyCamel string `json:"projectPathKey"` - Workdir string `json:"workdir"` - Side string `json:"side"` - LocalPath string `json:"local_path"` - LocalPathCamel string `json:"localPath"` - RemotePath string `json:"remote_path"` - RemotePathCamel string `json:"remotePath"` - FromPath string `json:"from_path"` - FromPathCamel string `json:"fromPath"` - SourcePathCamel string `json:"sourcePath"` - ToPath string `json:"to_path"` - ToPathCamel string `json:"toPath"` - Direction string `json:"direction"` - TargetPath string `json:"target_path"` - TargetPathCamel string `json:"targetPath"` - TransferID string `json:"transfer_id"` - TransferIDCamel string `json:"transferId"` - Recursive bool `json:"recursive"` - Overwrite bool `json:"overwrite"` -} - -type websocketGitRequestPayload struct { - Workdir string `json:"workdir"` - Args json.RawMessage `json:"args,omitempty"` -} - -const ( - websocketControlQueueSize = wscore.DefaultCtrlQueueSize -) - -// websocketConnection is one browser connection speaking the v1 JSON -// protocol. -// -// Deprecated: v1 JSON 协议实现,v2 对应实现为 internal/protocol/pbws.browserConn;v1 流量归零后整体删除。 -type websocketConnection struct { - cfg *config.Config - sm *session.Manager - - conn *websocket.Conn - req *http.Request - - // core 承载传输运行时(写泵/双队列/掉帧/心跳/空闲驱逐),v1 层只做 JSON 信封编解码与分发;done 是 core.Done() 的只读别名,供各转发器 select。 - core *wscore.Conn - done <-chan struct{} - - authorized bool - - historyEvents <-chan *gatewayv1.HistorySyncEvent - historyEventsCleanup func() - settingsEvents <-chan *gatewayv1.SettingsSyncEvent - settingsEventsCleanup func() - terminalEvents <-chan *gatewayv1.TerminalEvent - terminalEventsCleanup func() - sftpEvents <-chan *gatewayv1.SftpEvent - sftpEventsCleanup func() - chatQueueEvents <-chan *gatewayv1.ChatQueueEvent - chatQueueEventsCleanup func() - chatActivityEvents <-chan session.ConversationActivityEvent - chatActivityEventsCleanup func() - tunnelStateEvents <-chan *gatewayv1.TunnelStateSnapshot - tunnelStateEventsCleanup func() - statusEvents <-chan session.Status - statusEventsCleanup func() - - managedProcessEvents <-chan *gatewayv1.ManagedProcessSnapshot - managedProcessEventsCleanup func() - - terminalInterest *shared.TerminalInterestTracker - - chatStreamsMu sync.Mutex - chatStreams map[string]*chatStreamSubscription - - workspaceSubsMu sync.Mutex - workspaceSubs map[string]*workspaceActivitySubscription -} - -const maxHistoryListLimit = 200 -const defaultHistoryListPage = 1 -const defaultHistoryListPageSize = 80 - -// NewWebSocketServer serves the v1 JSON protocol on /ws. -// -// Deprecated: v1 JSON 协议入口,仅服务未升级的旧客户端(如未刷新的浏览器标签页);新客户端一律走 /ws/v2(internal/protocol/pbws.Server)。 -func NewWebSocketServer(cfg *config.Config, sm *session.Manager) http.Handler { - upgrader := websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { - return originAllowed(r) - }, - } - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - conn, err := upgrader.Upgrade(w, r, nil) - if err != nil { - return - } - conn.SetReadLimit(webSocketReadLimit(cfg)) - - state := &websocketConnection{ - cfg: cfg, - sm: sm, - conn: conn, - req: r, - terminalInterest: shared.NewTerminalInterestTracker(), - } - state.core = wscore.NewConn(conn, wscore.Config{ - WriteTimeout: cfg.WebSocketWriteTimeout, - QueueSize: cfg.WebSocketWriteQueueSize, - CtrlQueueSize: websocketControlQueueSize, - HeartbeatPeriod: cfg.WebSocketHeartbeatPeriod, - HeartbeatGrace: cfg.WebSocketHeartbeatGrace, - Remote: r.RemoteAddr, - OnClose: state.releaseSubscriptions, - }) - state.done = state.core.Done() - // Protocol-level pongs are produced by the browser's network stack - // even while the page's JS is throttled or frozen in a hidden tab, so - // they are the liveness signal that must count as inbound activity. - conn.SetPongHandler(func(string) error { - state.core.TouchInboundActivity() - return nil - }) - _ = conn.SetReadDeadline(time.Now().Add(state.core.IdleTimeout())) - defer state.close() - state.serve() - // authorized 只在读循环内写、serve 返回后同 goroutine 读,无竞争;计数与 handleAuth 的 Active+1 配对。 - if state.authorized { - observability.Usage.V1WSConnectionsActive.Add(-1) - } - }) -} - -func webSocketReadLimit(cfg *config.Config) int64 { - if cfg != nil && cfg.GRPCMaxMessageBytes > 0 { - return int64(cfg.GRPCMaxMessageBytes) - } - return int64(config.DefaultGRPCMaxMessageBytes) -} - -func (c *websocketConnection) serve() { - for { - var req websocketRequest - if err := c.conn.ReadJSON(&req); err != nil { - if errors.Is(err, io.EOF) { - return - } - return - } - - // Any inbound frame proves the client is alive — heartbeat pongs are - // not the only liveness evidence. - c.core.TouchInboundActivity() - - req.ID = strings.TrimSpace(req.ID) - req.Type = strings.TrimSpace(req.Type) - if req.Type == "pong" { - continue - } - // Pre-auth, nothing drains the write queues (writeLoop starts in - // handleAuth), so error envelopes would only pile up while the - // malformed frames keep refreshing the read deadline. The only valid - // first request is auth; anything else ends the connection. - if req.ID == "" { - _ = c.writeError("", "request id is required") - if !c.authorized { - return - } - continue - } - if req.Type == "" { - _ = c.writeError(req.ID, "request type is required") - if !c.authorized { - return - } - continue - } - - if req.Type == "auth" { - c.handleAuth(req) - continue - } - - if !c.authorized { - _ = c.writeError(req.ID, "unauthorized") - return - } - - // Subscription lifecycle must keep the client's frame order: a - // re-subscribe emits [unsubscribe, subscribe] back to back, and - // concurrent dispatch could let the stale unsubscribe cancel the fresh - // subscription. These handlers are lock-only and non-blocking, so they - // run inline on the read loop. - if req.Type == "chat.subscribe" || req.Type == "chat.unsubscribe" || - req.Type == "workspace.subscribe" || req.Type == "workspace.unsubscribe" { - c.dispatch(req) - continue - } - - go c.dispatch(req) - } -} - -func (c *websocketConnection) close() { - c.core.Close() -} - -// releaseSubscriptions 由 core 关闭时回调(恰好一次),释放本连接持有的全部 session 层订阅与 chat/workspace 流。 -func (c *websocketConnection) releaseSubscriptions() { - if c.historyEventsCleanup != nil { - c.historyEventsCleanup() - c.historyEventsCleanup = nil - } - if c.settingsEventsCleanup != nil { - c.settingsEventsCleanup() - c.settingsEventsCleanup = nil - } - if c.terminalEventsCleanup != nil { - c.terminalEventsCleanup() - c.terminalEventsCleanup = nil - } - if c.sftpEventsCleanup != nil { - c.sftpEventsCleanup() - c.sftpEventsCleanup = nil - } - if c.chatQueueEventsCleanup != nil { - c.chatQueueEventsCleanup() - c.chatQueueEventsCleanup = nil - } - if c.chatActivityEventsCleanup != nil { - c.chatActivityEventsCleanup() - c.chatActivityEventsCleanup = nil - } - if c.tunnelStateEventsCleanup != nil { - c.tunnelStateEventsCleanup() - c.tunnelStateEventsCleanup = nil - } - if c.statusEventsCleanup != nil { - c.statusEventsCleanup() - c.statusEventsCleanup = nil - } - if c.managedProcessEventsCleanup != nil { - c.managedProcessEventsCleanup() - c.managedProcessEventsCleanup = nil - } - c.cleanupChatStreamSubscriptions() - c.cleanupWorkspaceSubscriptions() -} - -func (c *websocketConnection) handleAuth(req websocketRequest) { - var payload websocketAuthPayload - if err := decodeWebSocketPayload(req.Payload, &payload); err != nil { - _ = c.writeError(req.ID, "invalid auth payload") - c.close() - return - } - - if !auth.ValidateToken(payload.Token, c.cfg.Token) { - _ = c.writeError(req.ID, "unauthorized") - c.close() - return - } - - c.authorized = true - // v1 使用打点:观察弃用链路流量归零后即可在后续版本删除 v1。 - observability.Usage.V1WSConnectionsTotal.Add(1) - observability.Usage.V1WSConnectionsActive.Add(1) - slog.Warn("deprecated v1 websocket connection established", - "remote", c.req.RemoteAddr, - ) - c.core.SetAuthorized() - // The pre-auth deadline was deliberately left un-refreshed; re-arm it now - // so a slow-to-auth client does not die moments after succeeding. - c.core.TouchInboundActivity() - c.core.StartWriteLoop() - c.startHistorySyncForwarder() - c.startSettingsSyncForwarder() - c.startTerminalEventForwarder() - c.startSftpEventForwarder() - c.startChatQueueEventForwarder() - c.startChatActivityForwarder() - c.startTunnelStateForwarder() - c.startManagedProcessStateForwarder() - c.startStatusEventForwarder() - c.core.StartHeartbeat(c.buildHeartbeatPing) - if err := c.writeResponse(req.ID, map[string]any{"ok": true}); err != nil { - c.close() - return - } - c.replayTerminalSessionSnapshot() - c.replayTunnelStateSnapshot() - c.replayManagedProcessSnapshot() - c.replayStatusSnapshot() -} - -func (c *websocketConnection) startHistorySyncForwarder() { - if c.historyEvents != nil || c.historyEventsCleanup != nil { - return - } - - historyEvents, cleanup := c.sm.SubscribeHistorySync() - c.historyEvents = historyEvents - c.historyEventsCleanup = cleanup - - go func() { - for { - select { - case <-c.done: - return - case event, ok := <-historyEvents: - if !ok { - return - } - if err := c.writeEvent("history.event", websocketHistorySyncPayload(event)); err != nil { - if errors.Is(err, errWriteQueueFull) { - continue - } - return - } - } - } - }() -} - -func (c *websocketConnection) startChatActivityForwarder() { - if c.chatActivityEvents != nil || c.chatActivityEventsCleanup != nil { - return - } - - activityEvents, cleanup := c.sm.SubscribeChatActivity() - c.chatActivityEvents = activityEvents - c.chatActivityEventsCleanup = cleanup - - go func() { - for { - select { - case <-c.done: - return - case event, ok := <-activityEvents: - if !ok { - return - } - if err := c.writeEvent("chat.activity", websocketChatActivityPayload(event)); err != nil { - if errors.Is(err, errWriteQueueFull) { - continue - } - return - } - } - } - }() -} - -// startStatusEventForwarder pushes agent online/offline transitions so the -// client does not depend on a (background-throttled) status poll to notice -// them. Frames are sheddable: the fallback poll reconciles missed ones. -func (c *websocketConnection) startStatusEventForwarder() { - if c.statusEvents != nil || c.statusEventsCleanup != nil { - return - } - - statusEvents, cleanup := c.sm.SubscribeStatus() - c.statusEvents = statusEvents - c.statusEventsCleanup = cleanup - - go func() { - for { - select { - case <-c.done: - return - case status, ok := <-statusEvents: - if !ok { - return - } - if err := c.writeEvent("status.event", status); err != nil { - if errors.Is(err, errWriteQueueFull) { - continue - } - return - } - } - } - }() -} - -// replayStatusSnapshot paints the freshly authenticated socket with the -// current agent status so no poll round-trip is needed after (re)connect. -func (c *websocketConnection) replayStatusSnapshot() { - _ = c.writeEvent("status.event", c.sm.Status()) -} - -func (c *websocketConnection) startSettingsSyncForwarder() { - if c.settingsEvents != nil || c.settingsEventsCleanup != nil { - return - } - - settingsEvents, cleanup := c.sm.SubscribeSettingsSync() - c.settingsEvents = settingsEvents - c.settingsEventsCleanup = cleanup - - go func() { - for { - select { - case <-c.done: - return - case event, ok := <-settingsEvents: - if !ok { - return - } - payload, err := websocketSettingsJSONPayload(event.GetSettingsJson()) - if err != nil { - return - } - if err := c.writeEvent("settings.event", payload); err != nil { - if errors.Is(err, errWriteQueueFull) { - continue - } - return - } - } - } - }() -} - -func (c *websocketConnection) startTerminalEventForwarder() { - if c.terminalEvents != nil || c.terminalEventsCleanup != nil { - return - } - - terminalEvents, cleanup := c.sm.SubscribeTerminalEvents() - c.terminalEvents = terminalEvents - c.terminalEventsCleanup = cleanup - - go func() { - for { - select { - case <-c.done: - return - case event, ok := <-terminalEvents: - if !ok { - return - } - if !c.shouldForwardTerminalEvent(event) { - continue - } - if err := c.writeEvent("terminal.event", websocketTerminalEventPayload(event)); err != nil { - if errors.Is(err, errWriteQueueFull) { - continue - } - return - } - } - } - }() -} - -func (c *websocketConnection) startSftpEventForwarder() { - if c.sftpEvents != nil || c.sftpEventsCleanup != nil { - return - } - - sftpEvents, cleanup := c.sm.SubscribeSftpEvents() - c.sftpEvents = sftpEvents - c.sftpEventsCleanup = cleanup - - go func() { - for { - select { - case <-c.done: - return - case event, ok := <-sftpEvents: - if !ok { - return - } - if !c.sm.WebSshTerminalEnabled() { - continue - } - if err := c.writeEvent("sftp.event", websocketSftpEventPayload(event)); err != nil { - if errors.Is(err, errWriteQueueFull) { - continue - } - return - } - } - } - }() -} - -func (c *websocketConnection) startChatQueueEventForwarder() { - if c.chatQueueEvents != nil || c.chatQueueEventsCleanup != nil { - return - } - - chatQueueEvents, cleanup := c.sm.SubscribeChatQueueEvents() - c.chatQueueEvents = chatQueueEvents - c.chatQueueEventsCleanup = cleanup - - go func() { - for { - select { - case <-c.done: - return - case event, ok := <-chatQueueEvents: - if !ok { - return - } - if err := c.writeEvent("chat_queue.event", websocketChatQueueEventPayload(event)); err != nil { - if errors.Is(err, errWriteQueueFull) { - continue - } - return - } - } - } - }() -} - -func (c *websocketConnection) replayTerminalSessionSnapshot() { - if !c.terminalFeaturesEnabled() { - return - } - for _, terminalSession := range c.sm.TerminalSessionSnapshot("") { - if !c.terminalSessionAllowed(terminalSession) { - continue - } - if err := c.writeEvent("terminal.event", websocketTerminalEventPayload(&gatewayv1.TerminalEvent{ - Kind: "created", - SessionId: terminalSession.GetId(), - ProjectPathKey: terminalSession.GetProjectPathKey(), - Session: terminalSession, - })); err != nil { - return - } - } -} - -func (c *websocketConnection) shouldForwardTerminalEvent(event *gatewayv1.TerminalEvent) bool { - return c.terminalEventAllowed(event) && c.terminalInterest.ShouldForward(event) -} - -// buildHeartbeatPing 为 wscore 心跳循环构造 v1 JSON ping 信封帧。 -func (c *websocketConnection) buildHeartbeatPing() (wscore.Frame, bool) { - data, err := json.Marshal(websocketEnvelope{ - Type: "ping", - Payload: map[string]any{ - "timestamp": time.Now().Unix(), - }, - }) - if err != nil { - return wscore.Frame{}, false - } - return wscore.Frame{ - Class: wscore.FramePing, - Kind: "ping", - MessageType: websocket.TextMessage, - Data: data, - }, true -} - -func (c *websocketConnection) dispatch(req websocketRequest) { - observability.Usage.V1WSRequestsTotal.Add(1) - handler := websocketRequestHandlers[req.Type] - if handler == nil { - _ = c.writeError(req.ID, "unsupported request type") - return - } - handler(c, req) -} - -func (c *websocketConnection) awaitAgentResponse( - requestID string, - envelope *gatewayv1.GatewayEnvelope, -) (*gatewayv1.AgentEnvelope, error) { - ctx, cancel := context.WithTimeout(context.Background(), c.cfg.RequestTimeout) - defer cancel() - - go func() { - select { - case <-c.done: - cancel() - case <-ctx.Done(): - } - }() - - return awaitAgentUnaryResponse(ctx, c.sm, requestID, envelope) -} - -func (c *websocketConnection) writeResponse(requestID string, payload any) error { - return c.writeEnvelope(websocketEnvelope{ - ID: requestID, - Type: "response", - Payload: payload, - }) -} - -func (c *websocketConnection) writePriorityResponse(requestID string, payload any) error { - return c.writeEnvelope(websocketEnvelope{ - ID: requestID, - Type: "response", - Payload: payload, - priority: true, - }) -} - -func (c *websocketConnection) writeError(requestID string, message string) error { - return c.writeEnvelope(websocketEnvelope{ - ID: requestID, - Type: "error", - Error: message, - }) -} - -func (c *websocketConnection) writeEvent(eventType string, payload any) error { - return c.writeEnvelope(websocketEnvelope{ - Type: eventType, - Payload: payload, - }) -} - -// errWriteQueueFull 是 wscore 掉帧错误的包内别名,保持既有 errors.Is 判定。 -var errWriteQueueFull = wscore.ErrWriteQueueFull - -// isControlEnvelopeType reports envelopes that must reach the client even -// when the data queue is congested: keep-alive pings and the signals a client -// needs to recover a shed stream. -func isControlEnvelopeType(envelopeType string) bool { - switch envelopeType { - case "ping", "error", "chat.subscription_reset", "chat.command_update": - return true - default: - return false - } -} - -// classifyEnvelope 将 v1 信封映射为 wscore 帧类别(拥塞策略),逐字保持原 writeEnvelope 的路由语义。 -func classifyEnvelope(envelope websocketEnvelope) wscore.FrameClass { - switch { - case envelope.Type == "ping": - return wscore.FramePing - case envelope.priority || isControlEnvelopeType(envelope.Type): - return wscore.FrameControl - case envelope.Type == "response" && envelope.ID != "": - return wscore.FrameResponse - default: - return wscore.FrameData - } -} - -// writeEnvelope 编码 v1 JSON 信封并交给共享写泵;拥塞策略(控制帧优先、数据掉帧、关联响应关连接)由帧类别声明、wscore 统一执行。 -func (c *websocketConnection) writeEnvelope(envelope websocketEnvelope) error { - data, err := json.Marshal(envelope) - if err != nil { - return err - } - return c.core.Enqueue(wscore.Frame{ - Class: classifyEnvelope(envelope), - RequestID: envelope.ID, - Kind: envelope.Type, - MessageType: websocket.TextMessage, - Data: data, - }) -} diff --git a/crates/agent-gateway/internal/server/websocket_chat_handlers.go b/crates/agent-gateway/internal/server/websocket_chat_handlers.go deleted file mode 100644 index c0d172e0a..000000000 --- a/crates/agent-gateway/internal/server/websocket_chat_handlers.go +++ /dev/null @@ -1,427 +0,0 @@ -// Deprecated: v1 JSON 协议的处理器/载荷塑形,已被 v2 信封直通(internal/protocol/pbws)取代;仅为旧客户端保留,流量归零后整体删除。 -package server - -import ( - "context" - "encoding/json" - "errors" - "strings" - "time" - - "github.com/google/uuid" - "github.com/liveagent/agent-gateway/internal/chatcmd" - "github.com/liveagent/agent-gateway/internal/config" - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" - "github.com/liveagent/agent-gateway/internal/session" -) - -// chatStreamSubscription is one persistent per-conversation subscription on a -// websocket connection. It survives run boundaries and ends only on -// chat.unsubscribe, a replacing chat.subscribe, or connection close. -type chatStreamSubscription struct { - conversationID string - cancel func() -} - -func (c *websocketConnection) handleChatSubscribe(req websocketRequest) { - var payload struct { - ConversationID string `json:"conversation_id"` - AfterSeq int64 `json:"after_seq"` - StreamEpoch string `json:"stream_epoch"` - } - if err := decodeWebSocketPayload(req.Payload, &payload); err != nil { - _ = c.writeError(req.ID, "invalid chat.subscribe payload") - return - } - conversationID := strings.TrimSpace(payload.ConversationID) - if conversationID == "" { - _ = c.writeError(req.ID, "conversation_id is required") - return - } - - sub := c.sm.SubscribeConversationStream(conversationID, payload.AfterSeq, payload.StreamEpoch) - - events := make([]map[string]any, 0, len(sub.Events)) - for _, event := range sub.Events { - events = append(events, event.Payload) - } - resp := map[string]any{ - "conversation_id": sub.ConversationID, - "stream_epoch": sub.StreamEpoch, - "latest_seq": sub.LatestSeq, - "reset": sub.Reset, - "activity": websocketRunActivityPayload(sub.Activity), - "snapshot": websocketRunSnapshotPayload(sub.Snapshot), - "events": events, - } - - // Register (replacing any previous subscription for this conversation) - // before responding so no live event published after the replay boundary - // is dropped. - entry := &chatStreamSubscription{ - conversationID: conversationID, - cancel: sub.Cleanup, - } - c.chatStreamsMu.Lock() - if c.chatStreams == nil { - c.chatStreams = make(map[string]*chatStreamSubscription) - } - if previous := c.chatStreams[conversationID]; previous != nil { - previous.cancel() - } - c.chatStreams[conversationID] = entry - c.chatStreamsMu.Unlock() - - if err := c.writeResponse(req.ID, resp); err != nil { - sub.Cleanup() - c.chatStreamsMu.Lock() - if c.chatStreams[conversationID] == entry { - delete(c.chatStreams, conversationID) - } - c.chatStreamsMu.Unlock() - // A shed subscribe response would otherwise dead-end the stream: the - // client's request just times out and nothing ever resubscribes. The - // control-queue reset re-arms its recovery loop. - if errors.Is(err, errWriteQueueFull) { - c.writeSubscriptionResetOrClose(conversationID) - } - return - } - - go c.forwardConversationEvents(conversationID, sub) -} - -// forwardConversationEvents pushes live stream events to the client. When the -// subscriber channel closes because it overflowed — or the connection's own -// write queue stays full — the client is told to re-subscribe (resume by -// after_seq replays the missed tail from the buffer). Congestion sheds this -// subscription, never the connection. -func (c *websocketConnection) forwardConversationEvents( - conversationID string, - sub *session.ConversationSubscription, -) { - defer sub.Cleanup() - for { - select { - case <-c.done: - return - case event, ok := <-sub.EventCh: - if !ok { - if sub.Overflowed() { - c.writeSubscriptionResetOrClose(conversationID) - } - return - } - if err := c.writeEvent("chat.event", event.Payload); err != nil { - if errors.Is(err, errWriteQueueFull) { - // The reset rides the control queue, so it overtakes the - // congested data backlog; stale queued chat.events are - // deduped client-side by seq after the resync. - c.writeSubscriptionResetOrClose(conversationID) - } - return - } - } - } -} - -// writeSubscriptionResetOrClose delivers the one signal that lets a client -// recover a shed conversation stream. If even the control queue cannot take -// it, the link is beyond load-shedding: closing forces a reconnect whose -// resubscribe (after_seq) is the only remaining path that cannot be lost. -func (c *websocketConnection) writeSubscriptionResetOrClose(conversationID string) { - if err := c.writeEvent("chat.subscription_reset", map[string]any{ - "conversation_id": conversationID, - }); err != nil { - c.close() - } -} - -// handleChatActivities answers from gateway state only — no agent round-trip -// — so clients can reconcile running conversations while the desktop is -// offline. -func (c *websocketConnection) handleChatActivities(req websocketRequest) { - var body struct{} - if err := decodeWebSocketPayload(req.Payload, &body); err != nil { - _ = c.writeError(req.ID, "invalid chat.activities payload") - return - } - _ = c.writeResponse(req.ID, map[string]any{ - "running_conversations": websocketRunningConversationsPayload(c.sm.ActiveConversationActivities()), - }) -} - -func (c *websocketConnection) handleChatUnsubscribe(req websocketRequest) { - var payload struct { - ConversationID string `json:"conversation_id"` - } - if err := decodeWebSocketPayload(req.Payload, &payload); err != nil { - _ = c.writeError(req.ID, "invalid chat.unsubscribe payload") - return - } - conversationID := strings.TrimSpace(payload.ConversationID) - - c.chatStreamsMu.Lock() - if sub := c.chatStreams[conversationID]; sub != nil { - sub.cancel() - delete(c.chatStreams, conversationID) - } - c.chatStreamsMu.Unlock() - - _ = c.writeResponse(req.ID, map[string]any{"ok": true}) -} - -func (c *websocketConnection) cleanupChatStreamSubscriptions() { - c.chatStreamsMu.Lock() - for conversationID, sub := range c.chatStreams { - sub.cancel() - delete(c.chatStreams, conversationID) - } - c.chatStreamsMu.Unlock() -} - -func (c *websocketConnection) handleChatPrepare(req websocketRequest) { - var payload struct { - Reason string `json:"reason"` - } - if err := decodeWebSocketPayload(req.Payload, &payload); err != nil { - _ = c.writeError(req.ID, "invalid chat.prepare payload") - return - } - - ctx, cancel := context.WithTimeout(context.Background(), chatcmd.PrepareTimeout(c.cfg)) - defer cancel() - if err := chatcmd.ProbeRuntime(ctx, c.sm); err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - - // Keep the response shape identical to status.get so older and newer WebUI - // clients can share one status normalizer. - _ = c.writePriorityResponse(req.ID, c.sm.Status()) -} - -func (c *websocketConnection) handleChatCommand(req websocketRequest) { - commandType, body, baseMessageRef, err := chatcmd.DecodeCommandPayload(req.Payload) - if err != nil { - _ = c.writeError(req.ID, "invalid chat command payload") - return - } - - switch commandType { - case "chat.submit": - baseMessageRef = nil - case "chat.edit_resend": - if baseMessageRef == nil { - _ = c.writeError(req.ID, "base_message_ref is required") - return - } - if err := chatcmd.ValidateMessageRef(baseMessageRef); err != nil { - _ = c.writeError(req.ID, err.Error()) - return - } - case "chat.cancel": - c.handleChatCancel(req) - return - default: - _ = c.writeError(req.ID, "unsupported chat command") - return - } - - if err := chatcmd.NormalizeRequestBody(&body); err != nil { - _ = c.writeError(req.ID, err.Error()) - return - } - - if existing, ok := c.sm.LookupChatCommand(body.ClientRequestID); ok { - c.respondChatCommandDeduped(req.ID, existing) - return - } - - if !c.sm.IsOnline() { - _ = c.writeError(req.ID, "agent offline") - return - } - probeCtx, probeCancel := context.WithTimeout( - context.Background(), chatcmd.PrepareTimeout(c.cfg), - ) - probeErr := chatcmd.ProbeRuntimeForCommand(probeCtx, c.sm) - probeCancel() - if probeErr != nil { - _ = c.writeError(req.ID, websocketErrorMessage(probeErr)) - return - } - - runID := "chat-command-" + uuid.NewString() - start := c.sm.StartChatCommand( - runID, - body.ConversationID, - body.Workdir, - body.ClientRequestID, - chatcmd.BuildAcceptedCommandPayloads(body, baseMessageRef), - ) - if start.Deduped { - c.respondChatCommandDeduped(req.ID, start) - return - } - updates, cleanupWatch := c.sm.WatchChatCommand(start.RunID) - - _ = c.writeChatCommandAcceptedResponse(req.ID, start) - - go c.forwardChatCommandUpdates(updates, cleanupWatch) - go chatcmd.DispatchAcceptedCommand( - context.Background(), c.cfg, c.sm, cleanupWatch, start, body, baseMessageRef, chatcmd.NewTraceID(), - ) -} - -// respondChatCommandDeduped answers a duplicated client_request_id with the -// canonical run and forwards its replayed/subsequent pre-stream updates. No -// dispatch happens here — the canonical submission owns delivery and its -// startup watchdog; this watch is bounded by cleanupChatCommandWatchAfter. -func (c *websocketConnection) respondChatCommandDeduped( - requestID string, - start session.ChatCommandStart, -) { - updates, cleanupWatch := c.sm.WatchChatCommand(start.RunID) - _ = c.writeChatCommandAcceptedResponse(requestID, start) - go c.forwardChatCommandUpdates(updates, cleanupWatch) - cleanupChatCommandWatchAfter(c.cfg, cleanupWatch) -} - -func (c *websocketConnection) writeChatCommandAcceptedResponse( - requestID string, - start session.ChatCommandStart, -) error { - return c.writePriorityResponse(requestID, map[string]any{ - "run_id": start.RunID, - "conversation_id": start.ConversationID, - "accepted_seq": start.AcceptedSeq, - "deduped": start.Deduped, - }) -} - -// forwardChatCommandUpdates relays pre-stream command outcomes (bound / -// queued_in_gui / failed) to the connection that issued the command. The -// watch is closed by the command's startup watchdog. -func (c *websocketConnection) forwardChatCommandUpdates( - updates <-chan session.ChatCommandUpdate, - cleanup func(), -) { - if cleanup != nil { - defer cleanup() - } - for { - select { - case <-c.done: - return - case update, ok := <-updates: - if !ok { - return - } - payload := map[string]any{ - "run_id": update.RunID, - "client_request_id": update.ClientRequestID, - "phase": update.Phase, - } - if update.ConversationID != "" { - payload["conversation_id"] = update.ConversationID - } - if update.ErrorCode != "" { - payload["error_code"] = update.ErrorCode - } - if update.Message != "" { - payload["message"] = update.Message - } - if err := c.writeEvent("chat.command_update", payload); err != nil { - return - } - } - } -} - -// cleanupChatCommandWatchAfter bounds a deduplicated submit's update watch. -// time.AfterFunc keeps no goroutine parked while it waits; cleanup is -// idempotent, so racing the forwarder's own deferred cleanup is harmless. -func cleanupChatCommandWatchAfter(cfg *config.Config, cleanup func()) { - if cleanup == nil { - return - } - timeout := chatcmd.StartTimeout(cfg) + chatcmd.RenderStartTimeout(cfg) - if timeout <= 0 { - timeout = 15 * time.Second - } - time.AfterFunc(timeout, cleanup) -} - -func (c *websocketConnection) handleChatCancel(req websocketRequest) { - raw := req.Payload - // chat.cancel arrives either directly or wrapped in a chat.command - // envelope ({type, payload}); unwrap the latter. - var wrapper struct { - Type string `json:"type"` - Payload json.RawMessage `json:"payload"` - } - if err := json.Unmarshal(raw, &wrapper); err == nil && - strings.TrimSpace(wrapper.Type) == "chat.cancel" && len(wrapper.Payload) > 0 { - raw = wrapper.Payload - } - - var payload struct { - ConversationID string `json:"conversation_id"` - RunID string `json:"run_id"` - } - if err := decodeWebSocketPayload(raw, &payload); err != nil { - _ = c.writeError(req.ID, "invalid chat.cancel payload") - return - } - payload.ConversationID = strings.TrimSpace(payload.ConversationID) - payload.RunID = strings.TrimSpace(payload.RunID) - if payload.ConversationID == "" { - _ = c.writeError(req.ID, "conversation_id is required") - return - } - if !c.sm.IsOnline() { - _ = c.writeError(req.ID, "agent offline") - return - } - - // The run is not terminalized here: the activity flips to "cancelling", - // the agent's real terminal signal wins, and a watchdog force-finishes if - // the agent never reports back. - runID, active := c.sm.MarkConversationCancelling(payload.ConversationID, payload.RunID) - if !active { - _ = c.writeResponse(req.ID, map[string]any{ - "ok": true, "run_id": "", "conversation_id": payload.ConversationID, - }) - return - } - - timeout := 10 * time.Second - if c.cfg != nil && c.cfg.WebSocketWriteTimeout > 0 { - timeout = c.cfg.WebSocketWriteTimeout - } - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - if err := c.sm.SendToAgentContext(ctx, &gatewayv1.GatewayEnvelope{ - RequestId: runID, - Timestamp: time.Now().Unix(), - Payload: chatcmd.BuildCancelCommandPayload(payload.ConversationID), - }); err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - - go watchChatCancel(c.sm, runID) - _ = c.writeResponse(req.ID, map[string]any{ - "ok": true, "run_id": runID, "conversation_id": payload.ConversationID, - }) -} - -const chatCancelWatchdogTimeout = 15 * time.Second - -func watchChatCancel(sm *session.Manager, runID string) { - time.Sleep(chatCancelWatchdogTimeout) - sm.ForceFinishRun(runID, "cancelled", "cancel_timeout", - "The desktop runtime did not confirm the cancellation in time.") -} diff --git a/crates/agent-gateway/internal/server/websocket_chat_queue_handlers.go b/crates/agent-gateway/internal/server/websocket_chat_queue_handlers.go deleted file mode 100644 index 0591fe4f3..000000000 --- a/crates/agent-gateway/internal/server/websocket_chat_queue_handlers.go +++ /dev/null @@ -1,100 +0,0 @@ -// Deprecated: v1 JSON 协议的处理器/载荷塑形,已被 v2 信封直通(internal/protocol/pbws)取代;仅为旧客户端保留,流量归零后整体删除。 -package server - -import ( - "strings" - "time" - - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" -) - -type websocketChatQueueRequestPayload struct { - ConversationID string `json:"conversation_id"` - ItemID string `json:"item_id"` - Direction string `json:"direction"` - Revision uint64 `json:"revision"` - DraftJSON string `json:"draft_json"` - UploadedFilesJSON string `json:"uploaded_files_json"` - RequestJSON string `json:"request_json"` -} - -func chatQueueActionFromRequestType(requestType string) string { - return strings.TrimPrefix(strings.TrimSpace(requestType), "chat_queue.") -} - -func (c *websocketConnection) handleChatQueueRequest(req websocketRequest) { - var body websocketChatQueueRequestPayload - if err := decodeWebSocketPayload(req.Payload, &body); err != nil { - _ = c.writeError(req.ID, "invalid "+req.Type+" payload") - return - } - action := chatQueueActionFromRequestType(req.Type) - conversationID := strings.TrimSpace(body.ConversationID) - if !c.sm.IsOnline() { - if action == "get" { - if event, ok := c.sm.ChatQueueSnapshot(conversationID); ok { - _ = c.writeResponse(req.ID, websocketChatQueueSnapshotResponsePayload(event)) - return - } - } - _ = c.writeError(req.ID, "agent offline") - return - } - - response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ - RequestId: req.ID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_ChatQueue{ - ChatQueue: &gatewayv1.ChatQueueRequest{ - Action: action, - ConversationId: conversationID, - ItemId: strings.TrimSpace(body.ItemID), - Direction: strings.TrimSpace(body.Direction), - Revision: body.Revision, - DraftJson: strings.TrimSpace(body.DraftJSON), - UploadedFilesJson: strings.TrimSpace(body.UploadedFilesJSON), - RequestJson: strings.TrimSpace(body.RequestJSON), - }, - }, - }) - if err != nil { - if action == "get" { - if event, ok := c.sm.ChatQueueSnapshot(conversationID); ok { - _ = c.writeResponse(req.ID, websocketChatQueueSnapshotResponsePayload(event)) - return - } - } - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - if errResp := response.GetError(); errResp != nil { - _ = c.writeError(req.ID, errResp.GetMessage()) - return - } - - resp := response.GetChatQueueResp() - if resp == nil { - _ = c.writeError(req.ID, "unexpected agent response") - return - } - - _ = c.writeResponse(req.ID, map[string]any{ - "accepted": resp.GetAccepted(), - "message": resp.GetMessage(), - "snapshot_json": resp.GetSnapshotJson(), - "item_json": resp.GetItemJson(), - "error_code": resp.GetErrorCode(), - "revision": resp.GetRevision(), - }) -} - -func websocketChatQueueSnapshotResponsePayload(event *gatewayv1.ChatQueueEvent) map[string]any { - return map[string]any{ - "accepted": true, - "message": "", - "snapshot_json": event.GetSnapshotJson(), - "item_json": "", - "error_code": "", - "revision": event.GetRevision(), - } -} diff --git a/crates/agent-gateway/internal/server/websocket_chat_shed_test.go b/crates/agent-gateway/internal/server/websocket_chat_shed_test.go deleted file mode 100644 index d76b7dc83..000000000 --- a/crates/agent-gateway/internal/server/websocket_chat_shed_test.go +++ /dev/null @@ -1,84 +0,0 @@ -package server - -import ( - "encoding/json" - "testing" - "time" - - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" - "github.com/liveagent/agent-gateway/internal/session" - "github.com/liveagent/agent-gateway/internal/transport/wscore" -) - -// A congested data queue must shed the chat subscription (reset rides the -// control queue so the client can resync by after_seq) and leave the -// connection itself untouched. -func TestForwardConversationEventsShedsSubscriptionOnQueueFull(t *testing.T) { - t.Parallel() - - sm := session.NewManager() - sm.RecordAuthentication("desktop-agent", "0.9.0", "session-shed") - sm.SetSession(session.NewAgentSession(sm.LatestAuthSnapshot())) - core := wscore.NewConn(nil, wscore.Config{ - QueueSize: 1, - WriteTimeout: 30 * time.Millisecond, - }) - c := &websocketConnection{ - sm: sm, - core: core, - done: core.Done(), - } - // Congest the data queue with nothing draining it. - core.Outbox <- wscore.Frame{Kind: "chat.event"} - - sub := sm.SubscribeConversationStream("conv-shed", 0, "") - forwarderDone := make(chan struct{}) - go func() { - defer close(forwarderDone) - c.forwardConversationEvents("conv-shed", sub) - }() - - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "run-shed", - Payload: &gatewayv1.AgentEnvelope_ChatControl{ - ChatControl: &gatewayv1.ChatControlEvent{ - RequestId: "run-shed", - ConversationId: "conv-shed", - Type: "started", - State: "running", - }, - }, - }) - tokenData, _ := json.Marshal(map[string]any{"text": "hello"}) - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "run-shed", - Payload: &gatewayv1.AgentEnvelope_ChatEvent{ - ChatEvent: &gatewayv1.ChatEvent{ - Type: gatewayv1.ChatEvent_TOKEN, - ConversationId: "conv-shed", - Data: string(tokenData), - }, - }, - }) - - select { - case frame := <-core.CtrlOutbox: - if frame.Kind != "chat.subscription_reset" { - t.Fatalf("control frame kind = %q, want chat.subscription_reset", frame.Kind) - } - case <-time.After(2 * time.Second): - t.Fatal("timed out waiting for chat.subscription_reset on the control queue") - } - - select { - case <-forwarderDone: - case <-time.After(2 * time.Second): - t.Fatal("forwarder did not exit after shedding the subscription") - } - - select { - case <-c.done: - t.Fatal("shedding a congested chat subscription closed the connection") - default: - } -} diff --git a/crates/agent-gateway/internal/server/websocket_connection_state_test.go b/crates/agent-gateway/internal/server/websocket_connection_state_test.go deleted file mode 100644 index 4c42dbd22..000000000 --- a/crates/agent-gateway/internal/server/websocket_connection_state_test.go +++ /dev/null @@ -1,106 +0,0 @@ -package server - -import ( - "testing" - - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" - "github.com/liveagent/agent-gateway/internal/protocol/shared" - "github.com/liveagent/agent-gateway/internal/session" -) - -func TestWebsocketTerminalPermissionsSeparateLocalAndSshSessions(t *testing.T) { - t.Parallel() - - manager := session.NewManager() - manager.ApplySettingsJSON(`{"remote":{"enableWebTerminal":false,"enableWebSshTerminal":true}}`) - conn := &websocketConnection{sm: manager} - localSession := &gatewayv1.TerminalSession{ - Id: "local-1", - Kind: "local", - } - sshSession := &gatewayv1.TerminalSession{ - Id: "ssh-1", - Kind: "ssh", - Ssh: &gatewayv1.TerminalSshMetadata{ - Status: "connected", - }, - } - - if conn.terminalSessionAllowed(localSession) { - t.Fatal("local terminal session should not be allowed when only web SSH terminal is enabled") - } - if !conn.terminalSessionAllowed(sshSession) { - t.Fatal("SSH terminal session should be allowed when web SSH terminal is enabled") - } - - manager.ApplySettingsJSON(`{"remote":{"enableWebTerminal":true,"enableWebSshTerminal":false}}`) - if !conn.terminalSessionAllowed(localSession) { - t.Fatal("local terminal session should be allowed when web terminal is enabled") - } - if conn.terminalSessionAllowed(sshSession) { - t.Fatal("SSH terminal session should not be allowed when web SSH terminal is disabled") - } -} - -func TestWebsocketTerminalSessionPayloadIncludesSftpEnabled(t *testing.T) { - t.Parallel() - - payload := websocketTerminalSessionPayload(&gatewayv1.TerminalSession{ - Id: "ssh-1", - Kind: "ssh", - Ssh: &gatewayv1.TerminalSshMetadata{ - SftpEnabled: true, - }, - }) - ssh, ok := payload["ssh"].(map[string]any) - if !ok { - t.Fatalf("ssh payload missing: %#v", payload["ssh"]) - } - if got := ssh["sftp_enabled"]; got != true { - t.Fatalf("sftp_enabled = %#v, want true", got) - } - if got := ssh["sftpEnabled"]; got != true { - t.Fatalf("sftpEnabled = %#v, want true", got) - } -} - -func TestWebsocketTerminalEventForwardingAllowsSshOnlyStatusEvents(t *testing.T) { - t.Parallel() - - manager := session.NewManager() - manager.ApplySettingsJSON(`{"remote":{"enableWebTerminal":false,"enableWebSshTerminal":true}}`) - conn := &websocketConnection{ - sm: manager, - terminalInterest: shared.NewTerminalInterestTracker(), - } - - if !conn.shouldForwardTerminalEvent(&gatewayv1.TerminalEvent{ - Kind: "reconnecting", - SessionId: "ssh-1", - ProjectPathKey: "project-1", - Session: &gatewayv1.TerminalSession{ - Id: "ssh-1", - ProjectPathKey: "project-1", - Kind: "ssh", - Ssh: &gatewayv1.TerminalSshMetadata{ - Status: "reconnecting", - ReconnectAttempt: 1, - }, - }, - }) { - t.Fatal("SSH metadata event should forward when only web SSH terminal is enabled") - } - - if conn.shouldForwardTerminalEvent(&gatewayv1.TerminalEvent{ - Kind: "created", - SessionId: "local-1", - ProjectPathKey: "project-1", - Session: &gatewayv1.TerminalSession{ - Id: "local-1", - ProjectPathKey: "project-1", - Kind: "local", - }, - }) { - t.Fatal("local metadata event should not forward when web terminal is disabled") - } -} diff --git a/crates/agent-gateway/internal/server/websocket_cron_handlers.go b/crates/agent-gateway/internal/server/websocket_cron_handlers.go deleted file mode 100644 index acf158030..000000000 --- a/crates/agent-gateway/internal/server/websocket_cron_handlers.go +++ /dev/null @@ -1,52 +0,0 @@ -// Deprecated: v1 JSON 协议的处理器/载荷塑形,已被 v2 信封直通(internal/protocol/pbws)取代;仅为旧客户端保留,流量归零后整体删除。 -package server - -import ( - "time" - - "github.com/liveagent/agent-gateway/internal/handler" - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" -) - -func (c *websocketConnection) handleCronManage(req websocketRequest) { - var body handler.CronManageRequestBody - if err := decodeWebSocketPayload(req.Payload, &body); err != nil { - _ = c.writeError(req.ID, "invalid cron.manage payload") - return - } - if !c.sm.IsOnline() { - _ = c.writeError(req.ID, "agent offline") - return - } - - response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ - RequestId: req.ID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_CronManage{ - CronManage: &gatewayv1.CronManageRequest{ - Action: body.Action, - TaskId: body.TaskID, - TaskJson: body.TaskJSON, - }, - }, - }) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - if errResp := response.GetError(); errResp != nil { - _ = c.writeError(req.ID, errResp.GetMessage()) - return - } - - resp := response.GetCronManageResp() - if resp == nil { - _ = c.writeError(req.ID, "unexpected agent response") - return - } - - _ = c.writeResponse(req.ID, map[string]any{ - "action": resp.GetAction(), - "result_json": resp.GetResultJson(), - }) -} diff --git a/crates/agent-gateway/internal/server/websocket_fs_handlers.go b/crates/agent-gateway/internal/server/websocket_fs_handlers.go deleted file mode 100644 index d21a597c5..000000000 --- a/crates/agent-gateway/internal/server/websocket_fs_handlers.go +++ /dev/null @@ -1,626 +0,0 @@ -// Deprecated: v1 JSON 协议的处理器/载荷塑形,已被 v2 信封直通(internal/protocol/pbws)取代;仅为旧客户端保留,流量归零后整体删除。 -package server - -import ( - "strings" - "time" - - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" -) - -func (c *websocketConnection) handleFsRoots(req websocketRequest) { - // Payload is intentionally empty; we still decode to reject unexpected fields. - var body struct{} - if err := decodeWebSocketPayload(req.Payload, &body); err != nil { - _ = c.writeError(req.ID, "invalid fs.roots payload") - return - } - - response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ - RequestId: req.ID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_FsRoots{ - FsRoots: &gatewayv1.FsRootsRequest{}, - }, - }) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - if errResp := response.GetError(); errResp != nil { - _ = c.writeError(req.ID, errResp.GetMessage()) - return - } - - resp := response.GetFsRootsResp() - if resp == nil { - _ = c.writeError(req.ID, "unexpected agent response") - return - } - - rootPayload := make([]map[string]any, 0, len(resp.GetRoots())) - for _, root := range resp.GetRoots() { - rootPayload = append(rootPayload, map[string]any{ - "id": root.GetId(), - "path": root.GetPath(), - "kind": root.GetKind(), - "label": root.GetLabel(), - }) - } - - _ = c.writeResponse(req.ID, map[string]any{ - "roots": rootPayload, - }) -} - -func (c *websocketConnection) handleFsListDirs(req websocketRequest) { - type payload struct { - Path string `json:"path"` - MaxResults *int `json:"max_results"` - } - - var body payload - if err := decodeWebSocketPayload(req.Payload, &body); err != nil { - _ = c.writeError(req.ID, "invalid fs.list_dirs payload") - return - } - - dir := strings.TrimSpace(body.Path) - if dir == "" { - _ = c.writeError(req.ID, "path is required") - return - } - - maxResults, err := websocketOptionalUint32(body.MaxResults, "max_results") - if err != nil { - _ = c.writeError(req.ID, err.Error()) - return - } - - response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ - RequestId: req.ID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_FsListDirs{ - FsListDirs: &gatewayv1.FsListDirsRequest{ - Path: dir, - MaxResults: maxResults, - }, - }, - }) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - if errResp := response.GetError(); errResp != nil { - _ = c.writeError(req.ID, errResp.GetMessage()) - return - } - - resp := response.GetFsListDirsResp() - if resp == nil { - _ = c.writeError(req.ID, "unexpected agent response") - return - } - - entryPayload := make([]map[string]any, 0, len(resp.GetEntries())) - for _, entry := range resp.GetEntries() { - entryPayload = append(entryPayload, map[string]any{ - "path": entry.GetPath(), - "name": entry.GetName(), - }) - } - - _ = c.writeResponse(req.ID, map[string]any{ - "path": strings.TrimSpace(resp.GetPath()), - "entries": entryPayload, - "truncated": resp.GetTruncated(), - }) -} - -func (c *websocketConnection) handleFsCreateProjectFolder(req websocketRequest) { - type payload struct { - Parent string `json:"parent"` - Name string `json:"name"` - } - - var body payload - if err := decodeWebSocketPayload(req.Payload, &body); err != nil { - _ = c.writeError(req.ID, "invalid fs.create_project_folder payload") - return - } - - parent := strings.TrimSpace(body.Parent) - name := strings.TrimSpace(body.Name) - if parent == "" { - _ = c.writeError(req.ID, "parent is required") - return - } - if name == "" { - _ = c.writeError(req.ID, "name is required") - return - } - - response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ - RequestId: req.ID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_FsCreateProjectFolder{ - FsCreateProjectFolder: &gatewayv1.FsCreateProjectFolderRequest{ - Parent: parent, - Name: name, - }, - }, - }) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - if errResp := response.GetError(); errResp != nil { - _ = c.writeError(req.ID, errResp.GetMessage()) - return - } - - resp := response.GetFsCreateProjectFolderResp() - if resp == nil { - _ = c.writeError(req.ID, "unexpected agent response") - return - } - - _ = c.writeResponse(req.ID, map[string]any{ - "path": strings.TrimSpace(resp.GetPath()), - }) -} - -func (c *websocketConnection) handleFsList(req websocketRequest) { - type payload struct { - Workdir string `json:"workdir"` - Path string `json:"path"` - Depth *int `json:"depth"` - Offset *int `json:"offset"` - MaxResults *int `json:"max_results"` - ShowHidden *bool `json:"show_hidden"` - } - - var body payload - if err := decodeWebSocketPayload(req.Payload, &body); err != nil { - _ = c.writeError(req.ID, "invalid fs.list payload") - return - } - - workdir := strings.TrimSpace(body.Workdir) - if workdir == "" { - _ = c.writeError(req.ID, "workdir is required") - return - } - - depth, err := websocketOptionalUint32(body.Depth, "depth") - if err != nil { - _ = c.writeError(req.ID, err.Error()) - return - } - offset, err := websocketOptionalUint32(body.Offset, "offset") - if err != nil { - _ = c.writeError(req.ID, err.Error()) - return - } - maxResults, err := websocketOptionalUint32(body.MaxResults, "max_results") - if err != nil { - _ = c.writeError(req.ID, err.Error()) - return - } - - response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ - RequestId: req.ID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_FsList{ - FsList: &gatewayv1.FsListRequest{ - Workdir: workdir, - Path: strings.TrimSpace(body.Path), - Depth: depth, - Offset: offset, - MaxResults: maxResults, - ShowHidden: body.ShowHidden, - }, - }, - }) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - if errResp := response.GetError(); errResp != nil { - _ = c.writeError(req.ID, errResp.GetMessage()) - return - } - - resp := response.GetFsListResp() - if resp == nil { - _ = c.writeError(req.ID, "unexpected agent response") - return - } - - _ = c.writeResponse(req.ID, websocketFsListResponsePayload(resp)) -} - -func (c *websocketConnection) handleFsReadEditableText(req websocketRequest) { - type payload struct { - Workdir string `json:"workdir"` - Path string `json:"path"` - } - - var body payload - if err := decodeWebSocketPayload(req.Payload, &body); err != nil { - _ = c.writeError(req.ID, "invalid fs.read_editable_text payload") - return - } - - workdir := strings.TrimSpace(body.Workdir) - path := strings.TrimSpace(body.Path) - if workdir == "" { - _ = c.writeError(req.ID, "workdir is required") - return - } - if path == "" { - _ = c.writeError(req.ID, "path is required") - return - } - - response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ - RequestId: req.ID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_FsReadEditableText{ - FsReadEditableText: &gatewayv1.FsReadEditableTextRequest{ - Workdir: workdir, - Path: path, - }, - }, - }) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - if errResp := response.GetError(); errResp != nil { - _ = c.writeError(req.ID, errResp.GetMessage()) - return - } - - resp := response.GetFsReadEditableTextResp() - if resp == nil { - _ = c.writeError(req.ID, "unexpected agent response") - return - } - - _ = c.writeResponse(req.ID, websocketFsReadEditableTextResponsePayload(resp)) -} - -func (c *websocketConnection) handleFsReadWorkspaceImage(req websocketRequest) { - type payload struct { - Workdir string `json:"workdir"` - Path string `json:"path"` - } - - var body payload - if err := decodeWebSocketPayload(req.Payload, &body); err != nil { - _ = c.writeError(req.ID, "invalid fs.read_workspace_image payload") - return - } - - workdir := strings.TrimSpace(body.Workdir) - path := strings.TrimSpace(body.Path) - if workdir == "" { - _ = c.writeError(req.ID, "workdir is required") - return - } - if path == "" { - _ = c.writeError(req.ID, "path is required") - return - } - - response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ - RequestId: req.ID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_FsReadWorkspaceImage{ - FsReadWorkspaceImage: &gatewayv1.FsReadWorkspaceImageRequest{ - Workdir: workdir, - Path: path, - }, - }, - }) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - if errResp := response.GetError(); errResp != nil { - _ = c.writeError(req.ID, errResp.GetMessage()) - return - } - - resp := response.GetFsReadWorkspaceImageResp() - if resp == nil { - _ = c.writeError(req.ID, "unexpected agent response") - return - } - - _ = c.writeResponse(req.ID, websocketFsReadWorkspaceImageResponsePayload(resp)) -} - -func (c *websocketConnection) handleFsWriteText(req websocketRequest) { - type payload struct { - Workdir string `json:"workdir"` - Path string `json:"path"` - Content string `json:"content"` - Mode string `json:"mode"` - ExpectedMtimeMs *uint64 `json:"expected_mtime_ms"` - ExpectedContentHash *string `json:"expected_content_hash"` - } - - var body payload - if err := decodeWebSocketPayload(req.Payload, &body); err != nil { - _ = c.writeError(req.ID, "invalid fs.write_text payload") - return - } - - workdir := strings.TrimSpace(body.Workdir) - path := strings.TrimSpace(body.Path) - if workdir == "" { - _ = c.writeError(req.ID, "workdir is required") - return - } - if path == "" { - _ = c.writeError(req.ID, "path is required") - return - } - mode := strings.TrimSpace(body.Mode) - if mode == "" { - mode = "rewrite" - } - expectedHash := "" - hasExpectedHash := false - if body.ExpectedContentHash != nil { - expectedHash = strings.TrimSpace(*body.ExpectedContentHash) - hasExpectedHash = true - } - expectedMtime := uint64(0) - hasExpectedMtime := false - if body.ExpectedMtimeMs != nil { - expectedMtime = *body.ExpectedMtimeMs - hasExpectedMtime = true - } - - response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ - RequestId: req.ID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_FsWriteText{ - FsWriteText: &gatewayv1.FsWriteTextRequest{ - Workdir: workdir, - Path: path, - Content: body.Content, - Mode: mode, - ExpectedMtimeMs: expectedMtime, - ExpectedContentHash: expectedHash, - HasExpectedMtimeMs: hasExpectedMtime, - HasExpectedContentHash: hasExpectedHash, - }, - }, - }) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - if errResp := response.GetError(); errResp != nil { - _ = c.writeError(req.ID, errResp.GetMessage()) - return - } - - resp := response.GetFsWriteTextResp() - if resp == nil { - _ = c.writeError(req.ID, "unexpected agent response") - return - } - - _ = c.writeResponse(req.ID, websocketFsWriteTextResponsePayload(resp)) -} - -func (c *websocketConnection) handleFsCreateDir(req websocketRequest) { - type payload struct { - Workdir string `json:"workdir"` - Path string `json:"path"` - } - - var body payload - if err := decodeWebSocketPayload(req.Payload, &body); err != nil { - _ = c.writeError(req.ID, "invalid fs.create_dir payload") - return - } - - workdir := strings.TrimSpace(body.Workdir) - path := strings.TrimSpace(body.Path) - if workdir == "" { - _ = c.writeError(req.ID, "workdir is required") - return - } - if path == "" { - _ = c.writeError(req.ID, "path is required") - return - } - - response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ - RequestId: req.ID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_FsCreateDir{ - FsCreateDir: &gatewayv1.FsCreateDirRequest{ - Workdir: workdir, - Path: path, - }, - }, - }) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - if errResp := response.GetError(); errResp != nil { - _ = c.writeError(req.ID, errResp.GetMessage()) - return - } - - resp := response.GetFsCreateDirResp() - if resp == nil { - _ = c.writeError(req.ID, "unexpected agent response") - return - } - - _ = c.writeResponse(req.ID, websocketFsCreateDirResponsePayload(resp)) -} - -func (c *websocketConnection) handleFsRename(req websocketRequest) { - type payload struct { - Workdir string `json:"workdir"` - FromPath string `json:"from_path"` - ToPath string `json:"to_path"` - } - - var body payload - if err := decodeWebSocketPayload(req.Payload, &body); err != nil { - _ = c.writeError(req.ID, "invalid fs.rename payload") - return - } - - workdir := strings.TrimSpace(body.Workdir) - fromPath := strings.TrimSpace(body.FromPath) - toPath := strings.TrimSpace(body.ToPath) - if workdir == "" { - _ = c.writeError(req.ID, "workdir is required") - return - } - if fromPath == "" { - _ = c.writeError(req.ID, "from_path is required") - return - } - if toPath == "" { - _ = c.writeError(req.ID, "to_path is required") - return - } - - response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ - RequestId: req.ID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_FsRename{ - FsRename: &gatewayv1.FsRenameRequest{ - Workdir: workdir, - FromPath: fromPath, - ToPath: toPath, - }, - }, - }) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - if errResp := response.GetError(); errResp != nil { - _ = c.writeError(req.ID, errResp.GetMessage()) - return - } - - resp := response.GetFsRenameResp() - if resp == nil { - _ = c.writeError(req.ID, "unexpected agent response") - return - } - - _ = c.writeResponse(req.ID, websocketFsRenameResponsePayload(resp)) -} - -func (c *websocketConnection) handleFsDelete(req websocketRequest) { - type payload struct { - Workdir string `json:"workdir"` - Path string `json:"path"` - } - - var body payload - if err := decodeWebSocketPayload(req.Payload, &body); err != nil { - _ = c.writeError(req.ID, "invalid fs.delete payload") - return - } - - workdir := strings.TrimSpace(body.Workdir) - path := strings.TrimSpace(body.Path) - if workdir == "" { - _ = c.writeError(req.ID, "workdir is required") - return - } - if path == "" { - _ = c.writeError(req.ID, "path is required") - return - } - - response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ - RequestId: req.ID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_FsDelete{ - FsDelete: &gatewayv1.FsDeleteRequest{ - Workdir: workdir, - Path: path, - }, - }, - }) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - if errResp := response.GetError(); errResp != nil { - _ = c.writeError(req.ID, errResp.GetMessage()) - return - } - - resp := response.GetFsDeleteResp() - if resp == nil { - _ = c.writeError(req.ID, "unexpected agent response") - return - } - - _ = c.writeResponse(req.ID, websocketFsDeleteResponsePayload(resp)) -} - -func websocketFsListResponsePayload(resp *gatewayv1.FsListResponse) map[string]any { - entryPayload := make([]map[string]any, 0, len(resp.GetEntries())) - for _, entry := range resp.GetEntries() { - entryPayload = append(entryPayload, websocketProtoPayload(entry, false)) - } - - var path any - if resp.GetHasPath() { - path = resp.GetPath() - } - - return map[string]any{ - "path": path, - "depth": resp.GetDepth(), - "offset": resp.GetOffset(), - "maxResults": resp.GetMaxResults(), - "total": resp.GetTotal(), - "hasMore": resp.GetHasMore(), - "entries": entryPayload, - } -} - -func websocketFsReadEditableTextResponsePayload(resp *gatewayv1.FsReadEditableTextResponse) map[string]any { - return websocketProtoPayload(resp, false) -} - -func websocketFsReadWorkspaceImageResponsePayload(resp *gatewayv1.FsReadWorkspaceImageResponse) map[string]any { - return websocketProtoPayload(resp, false) -} - -func websocketFsWriteTextResponsePayload(resp *gatewayv1.FsWriteTextResponse) map[string]any { - return websocketProtoPayload(resp, false) -} - -func websocketFsCreateDirResponsePayload(resp *gatewayv1.FsCreateDirResponse) map[string]any { - return websocketProtoPayload(resp, false) -} - -func websocketFsRenameResponsePayload(resp *gatewayv1.FsRenameResponse) map[string]any { - return websocketProtoPayload(resp, false) -} - -func websocketFsDeleteResponsePayload(resp *gatewayv1.FsDeleteResponse) map[string]any { - return websocketProtoPayload(resp, false) -} diff --git a/crates/agent-gateway/internal/server/websocket_git_handlers.go b/crates/agent-gateway/internal/server/websocket_git_handlers.go deleted file mode 100644 index d6e2d8986..000000000 --- a/crates/agent-gateway/internal/server/websocket_git_handlers.go +++ /dev/null @@ -1,70 +0,0 @@ -// Deprecated: v1 JSON 协议的处理器/载荷塑形,已被 v2 信封直通(internal/protocol/pbws)取代;仅为旧客户端保留,流量归零后整体删除。 -package server - -import ( - "strings" - "time" - - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" -) - -func gitActionFromRequestType(requestType string) string { - return strings.TrimPrefix(strings.TrimSpace(requestType), "git.") -} - -func gitActionIsWrite(action string) bool { - switch action { - case "init", "switch_branch", "create_branch", "stage", "stage_all", "unstage", "unstage_all", "discard", "discard_all", "add_to_gitignore", "commit", "fetch", "pull", "set_remote", "push", "delete_branch", "rename_branch", "stash_push", "stash_pop": - return true - default: - return false - } -} - -func (c *websocketConnection) handleGitRequest(req websocketRequest) { - action := gitActionFromRequestType(req.Type) - if gitActionIsWrite(action) && !c.sm.WebGitEnabled() { - _ = c.writeError(req.ID, "web git is disabled in desktop Remote settings") - return - } - - var body websocketGitRequestPayload - if err := decodeWebSocketPayload(req.Payload, &body); err != nil { - _ = c.writeError(req.ID, "invalid "+req.Type+" payload") - return - } - argsJSON := strings.TrimSpace(string(body.Args)) - if argsJSON == "" { - argsJSON = "{}" - } - response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ - RequestId: req.ID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_GitRequest{ - GitRequest: &gatewayv1.GitRequest{ - Action: action, - Workdir: strings.TrimSpace(body.Workdir), - ArgsJson: argsJSON, - }, - }, - }) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - if errResp := response.GetError(); errResp != nil { - _ = c.writeError(req.ID, errResp.GetMessage()) - return - } - resp := response.GetGitResponse() - if resp == nil { - _ = c.writeError(req.ID, "unexpected agent response") - return - } - payload, err := unmarshalJSONPayload(resp.GetResultJson()) - if err != nil { - _ = c.writeError(req.ID, err.Error()) - return - } - _ = c.writeResponse(req.ID, payload) -} diff --git a/crates/agent-gateway/internal/server/websocket_history_handlers.go b/crates/agent-gateway/internal/server/websocket_history_handlers.go deleted file mode 100644 index 235528e17..000000000 --- a/crates/agent-gateway/internal/server/websocket_history_handlers.go +++ /dev/null @@ -1,582 +0,0 @@ -// Deprecated: v1 JSON 协议的处理器/载荷塑形,已被 v2 信封直通(internal/protocol/pbws)取代;仅为旧客户端保留,流量归零后整体删除。 -package server - -import ( - "encoding/json" - "strings" - "time" - - "github.com/liveagent/agent-gateway/internal/chatcmd" - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" -) - -func (c *websocketConnection) handleHistoryList(req websocketRequest) { - type payload struct { - Page int `json:"page"` - PageSize int `json:"page_size"` - Cwd string `json:"cwd"` - CwdEmpty bool `json:"cwd_empty"` - } - - var body payload - if err := decodeWebSocketPayload(req.Payload, &body); err != nil { - _ = c.writeError(req.ID, "invalid history.list payload") - return - } - page := body.Page - if page <= 0 { - page = defaultHistoryListPage - } - pageSize := body.PageSize - if pageSize <= 0 { - pageSize = defaultHistoryListPageSize - } else if pageSize > maxHistoryListLimit { - pageSize = maxHistoryListLimit - } - - response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ - RequestId: req.ID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_HistoryList{ - HistoryList: &gatewayv1.HistoryListRequest{ - Page: int32(page), - PageSize: int32(pageSize), - Cwd: strings.TrimSpace(body.Cwd), - CwdEmpty: body.CwdEmpty, - }, - }, - }) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - if errResp := response.GetError(); errResp != nil { - _ = c.writeError(req.ID, errResp.GetMessage()) - return - } - - resp := response.GetHistoryListResp() - if resp == nil { - _ = c.writeError(req.ID, "unexpected agent response") - return - } - - conversations := make([]map[string]any, 0, len(resp.GetConversations())) - for _, conversation := range resp.GetConversations() { - conversations = append(conversations, websocketConversationSummaryPayload(conversation)) - } - - _ = c.writeResponse(req.ID, map[string]any{ - "conversations": conversations, - "total_count": resp.GetTotalCount(), - "running_conversations": websocketRunningConversationsPayload(c.sm.ActiveConversationActivities()), - }) -} - -func (c *websocketConnection) handleHistoryWorkdirs(req websocketRequest) { - var body struct{} - if err := decodeWebSocketPayload(req.Payload, &body); err != nil { - _ = c.writeError(req.ID, "invalid history.workdirs payload") - return - } - - response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ - RequestId: req.ID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_HistoryWorkdirs{ - HistoryWorkdirs: &gatewayv1.HistoryWorkdirsRequest{}, - }, - }) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - if errResp := response.GetError(); errResp != nil { - _ = c.writeError(req.ID, errResp.GetMessage()) - return - } - - resp := response.GetHistoryWorkdirsResp() - if resp == nil { - _ = c.writeError(req.ID, "unexpected agent response") - return - } - - workdirs := make([]map[string]any, 0, len(resp.GetWorkdirs())) - for _, workdir := range resp.GetWorkdirs() { - workdirs = append(workdirs, map[string]any{ - "path": workdir.GetPath(), - "conversation_count": workdir.GetConversationCount(), - "updated_at": workdir.GetUpdatedAt(), - }) - } - - _ = c.writeResponse(req.ID, map[string]any{ - "workdirs": workdirs, - }) -} - -func (c *websocketConnection) handleHistorySharedList(req websocketRequest) { - type payload struct { - Page int `json:"page"` - PageSize int `json:"page_size"` - } - - var body payload - if err := decodeWebSocketPayload(req.Payload, &body); err != nil { - _ = c.writeError(req.ID, "invalid history.shared_list payload") - return - } - page := body.Page - if page <= 0 { - page = defaultHistoryListPage - } - pageSize := body.PageSize - if pageSize <= 0 { - pageSize = defaultHistoryListPageSize - } else if pageSize > maxHistoryListLimit { - pageSize = maxHistoryListLimit - } - - argsJSON, err := json.Marshal(map[string]any{ - "page": page, - "page_size": pageSize, - }) - if err != nil { - _ = c.writeError(req.ID, "invalid history.shared_list payload") - return - } - - response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ - RequestId: req.ID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_MemoryManage{ - MemoryManage: &gatewayv1.MemoryManageRequest{ - Command: "history_shared_list", - ArgsJson: string(argsJSON), - }, - }, - }) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - if errResp := response.GetError(); errResp != nil { - _ = c.writeError(req.ID, errResp.GetMessage()) - return - } - - resp := response.GetMemoryManageResp() - if resp == nil { - _ = c.writeError(req.ID, "unexpected agent response") - return - } - - var result struct { - Conversations []map[string]any `json:"conversations"` - TotalCount int `json:"total_count"` - } - if err := json.Unmarshal([]byte(resp.GetResultJson()), &result); err != nil { - _ = c.writeError(req.ID, "invalid history.shared_list response") - return - } - - _ = c.writeResponse(req.ID, map[string]any{ - "conversations": result.Conversations, - "total_count": result.TotalCount, - }) -} - -func (c *websocketConnection) handleHistoryGet(req websocketRequest) { - type payload struct { - ConversationID string `json:"conversation_id"` - MaxMessages int32 `json:"max_messages"` - } - - var body payload - if err := decodeWebSocketPayload(req.Payload, &body); err != nil { - _ = c.writeError(req.ID, "invalid history.get payload") - return - } - conversationID, err := requireTrimmedWebSocketString(body.ConversationID, "conversation_id") - if err != nil { - _ = c.writeError(req.ID, err.Error()) - return - } - - response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ - RequestId: req.ID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_HistoryGet{ - HistoryGet: &gatewayv1.HistoryGetRequest{ - ConversationId: conversationID, - MaxMessages: body.MaxMessages, - }, - }, - }) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - if errResp := response.GetError(); errResp != nil { - _ = c.writeError(req.ID, errResp.GetMessage()) - return - } - - resp := response.GetHistoryGetResp() - if resp == nil { - _ = c.writeError(req.ID, "unexpected agent response") - return - } - - _ = c.writeResponse(req.ID, map[string]any{ - "conversation_id": resp.GetConversationId(), - "messages_json": resp.GetMessagesJson(), - "total_message_count": resp.GetTotalMessageCount(), - "returned_message_count": resp.GetReturnedMessageCount(), - "has_more": resp.GetHasMore(), - "conversation": websocketConversationSummaryPayload(resp.GetConversation()), - }) -} - -func (c *websocketConnection) handleHistoryPrefix(req websocketRequest) { - type payload struct { - ConversationID string `json:"conversation_id"` - MaxMessages int32 `json:"max_messages"` - BaseMessageRef *chatcmd.MessageRef `json:"base_message_ref"` - } - - var body payload - if err := decodeWebSocketPayload(req.Payload, &body); err != nil { - _ = c.writeError(req.ID, "invalid history.prefix payload") - return - } - conversationID, err := requireTrimmedWebSocketString(body.ConversationID, "conversation_id") - if err != nil { - _ = c.writeError(req.ID, err.Error()) - return - } - if body.BaseMessageRef == nil { - _ = c.writeError(req.ID, "base_message_ref is required") - return - } - if err := chatcmd.ValidateMessageRef(body.BaseMessageRef); err != nil { - _ = c.writeError(req.ID, err.Error()) - return - } - - response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ - RequestId: req.ID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_HistoryPrefix{ - HistoryPrefix: &gatewayv1.HistoryPrefixRequest{ - ConversationId: conversationID, - MaxMessages: body.MaxMessages, - BaseMessageRef: chatcmd.BuildProtoMessageRef(body.BaseMessageRef), - }, - }, - }) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - if errResp := response.GetError(); errResp != nil { - _ = c.writeError(req.ID, errResp.GetMessage()) - return - } - - resp := response.GetHistoryPrefixResp() - if resp == nil { - _ = c.writeError(req.ID, "unexpected agent response") - return - } - - _ = c.writeResponse(req.ID, map[string]any{ - "conversation_id": resp.GetConversationId(), - "messages_json": resp.GetMessagesJson(), - "total_message_count": resp.GetTotalMessageCount(), - "returned_message_count": resp.GetReturnedMessageCount(), - "has_more": resp.GetHasMore(), - "conversation": websocketConversationSummaryPayload(resp.GetConversation()), - }) -} - -func (c *websocketConnection) handleHistoryRename(req websocketRequest) { - type payload struct { - ConversationID string `json:"conversation_id"` - Title string `json:"title"` - } - - var body payload - if err := decodeWebSocketPayload(req.Payload, &body); err != nil { - _ = c.writeError(req.ID, "invalid history.rename payload") - return - } - conversationID, err := requireTrimmedWebSocketString(body.ConversationID, "conversation_id") - if err != nil { - _ = c.writeError(req.ID, err.Error()) - return - } - title, err := requireTrimmedWebSocketString(body.Title, "title") - if err != nil { - _ = c.writeError(req.ID, err.Error()) - return - } - - response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ - RequestId: req.ID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_HistoryRename{ - HistoryRename: &gatewayv1.HistoryRenameRequest{ - ConversationId: conversationID, - Title: title, - }, - }, - }) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - if errResp := response.GetError(); errResp != nil { - _ = c.writeError(req.ID, errResp.GetMessage()) - return - } - - resp := response.GetHistoryRenameResp() - if resp == nil || resp.GetConversation() == nil { - _ = c.writeError(req.ID, "unexpected agent response") - return - } - - conversation := resp.GetConversation() - _ = c.writeResponse(req.ID, websocketConversationSummaryPayload(conversation)) -} - -func (c *websocketConnection) handleHistoryBranch(req websocketRequest) { - type payload struct { - ConversationID string `json:"conversation_id"` - BaseMessageRef *chatcmd.MessageRef `json:"base_message_ref"` - } - - var body payload - if err := decodeWebSocketPayload(req.Payload, &body); err != nil { - _ = c.writeError(req.ID, "invalid history.branch payload") - return - } - conversationID, err := requireTrimmedWebSocketString(body.ConversationID, "conversation_id") - if err != nil { - _ = c.writeError(req.ID, err.Error()) - return - } - if body.BaseMessageRef == nil { - _ = c.writeError(req.ID, "base_message_ref is required") - return - } - if err := chatcmd.ValidateMessageRef(body.BaseMessageRef); err != nil { - _ = c.writeError(req.ID, err.Error()) - return - } - - response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ - RequestId: req.ID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_HistoryBranch{ - HistoryBranch: &gatewayv1.HistoryBranchRequest{ - ConversationId: conversationID, - BaseMessageRef: chatcmd.BuildProtoMessageRef(body.BaseMessageRef), - }, - }, - }) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - if errResp := response.GetError(); errResp != nil { - _ = c.writeError(req.ID, errResp.GetMessage()) - return - } - - resp := response.GetHistoryBranchResp() - if resp == nil || resp.GetConversation() == nil { - _ = c.writeError(req.ID, "unexpected agent response") - return - } - - _ = c.writeResponse(req.ID, websocketConversationSummaryPayload(resp.GetConversation())) -} - -func (c *websocketConnection) handleHistoryPin(req websocketRequest) { - type payload struct { - ConversationID string `json:"conversation_id"` - IsPinned bool `json:"is_pinned"` - } - - var body payload - if err := decodeWebSocketPayload(req.Payload, &body); err != nil { - _ = c.writeError(req.ID, "invalid history.pin payload") - return - } - conversationID, err := requireTrimmedWebSocketString(body.ConversationID, "conversation_id") - if err != nil { - _ = c.writeError(req.ID, err.Error()) - return - } - - response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ - RequestId: req.ID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_HistoryPin{ - HistoryPin: &gatewayv1.HistoryPinRequest{ - ConversationId: conversationID, - IsPinned: body.IsPinned, - }, - }, - }) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - if errResp := response.GetError(); errResp != nil { - _ = c.writeError(req.ID, errResp.GetMessage()) - return - } - - resp := response.GetHistoryPinResp() - if resp == nil || resp.GetConversation() == nil { - _ = c.writeError(req.ID, "unexpected agent response") - return - } - - _ = c.writeResponse(req.ID, websocketConversationSummaryPayload(resp.GetConversation())) -} - -func (c *websocketConnection) handleHistoryShareGet(req websocketRequest) { - type payload struct { - ConversationID string `json:"conversation_id"` - } - - var body payload - if err := decodeWebSocketPayload(req.Payload, &body); err != nil { - _ = c.writeError(req.ID, "invalid history.share.get payload") - return - } - conversationID, err := requireTrimmedWebSocketString(body.ConversationID, "conversation_id") - if err != nil { - _ = c.writeError(req.ID, err.Error()) - return - } - - response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ - RequestId: req.ID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_HistoryShareGet{ - HistoryShareGet: &gatewayv1.HistoryShareGetRequest{ - ConversationId: conversationID, - }, - }, - }) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - if errResp := response.GetError(); errResp != nil { - _ = c.writeError(req.ID, errResp.GetMessage()) - return - } - - resp := response.GetHistoryShareGetResp() - if resp == nil || resp.GetShare() == nil { - _ = c.writeError(req.ID, "unexpected agent response") - return - } - - _ = c.writeResponse(req.ID, websocketHistoryShareStatusPayload(resp.GetShare())) -} - -func (c *websocketConnection) handleHistoryShareSet(req websocketRequest) { - type payload struct { - ConversationID string `json:"conversation_id"` - Enabled bool `json:"enabled"` - RedactToolContent *bool `json:"redact_tool_content,omitempty"` - } - - var body payload - if err := decodeWebSocketPayload(req.Payload, &body); err != nil { - _ = c.writeError(req.ID, "invalid history.share.set payload") - return - } - conversationID, err := requireTrimmedWebSocketString(body.ConversationID, "conversation_id") - if err != nil { - _ = c.writeError(req.ID, err.Error()) - return - } - - response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ - RequestId: req.ID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_HistoryShareSet{ - HistoryShareSet: &gatewayv1.HistoryShareSetRequest{ - ConversationId: conversationID, - Enabled: body.Enabled, - RedactToolContent: body.RedactToolContent, - }, - }, - }) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - if errResp := response.GetError(); errResp != nil { - _ = c.writeError(req.ID, errResp.GetMessage()) - return - } - - resp := response.GetHistoryShareSetResp() - if resp == nil || resp.GetShare() == nil { - _ = c.writeError(req.ID, "unexpected agent response") - return - } - - _ = c.writeResponse(req.ID, websocketHistoryShareStatusPayload(resp.GetShare())) -} - -func (c *websocketConnection) handleHistoryDelete(req websocketRequest) { - type payload struct { - ConversationID string `json:"conversation_id"` - } - - var body payload - if err := decodeWebSocketPayload(req.Payload, &body); err != nil { - _ = c.writeError(req.ID, "invalid history.delete payload") - return - } - conversationID, err := requireTrimmedWebSocketString(body.ConversationID, "conversation_id") - if err != nil { - _ = c.writeError(req.ID, err.Error()) - return - } - - response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ - RequestId: req.ID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_HistoryDelete{ - HistoryDelete: &gatewayv1.HistoryDeleteRequest{ - ConversationId: conversationID, - }, - }, - }) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - if errResp := response.GetError(); errResp != nil { - _ = c.writeError(req.ID, errResp.GetMessage()) - return - } - if response.GetHistoryDeleteResp() == nil { - _ = c.writeError(req.ID, "unexpected agent response") - return - } - - _ = c.writeResponse(req.ID, map[string]any{"ok": true}) -} diff --git a/crates/agent-gateway/internal/server/websocket_memory_handlers.go b/crates/agent-gateway/internal/server/websocket_memory_handlers.go deleted file mode 100644 index d30f87505..000000000 --- a/crates/agent-gateway/internal/server/websocket_memory_handlers.go +++ /dev/null @@ -1,74 +0,0 @@ -// Deprecated: v1 JSON 协议的处理器/载荷塑形,已被 v2 信封直通(internal/protocol/pbws)取代;仅为旧客户端保留,流量归零后整体删除。 -package server - -import ( - "encoding/json" - "strings" - "time" - - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" -) - -func (c *websocketConnection) handleMemoryManage(req websocketRequest) { - type payload struct { - Command string `json:"command"` - Args json.RawMessage `json:"args"` - } - - var body payload - if err := decodeWebSocketPayload(req.Payload, &body); err != nil { - _ = c.writeError(req.ID, "invalid memory.manage payload") - return - } - - command := strings.TrimSpace(body.Command) - if command == "" { - _ = c.writeError(req.ID, "command is required") - return - } - if !strings.HasPrefix(command, "memory_") { - _ = c.writeError(req.ID, "unsupported memory command") - return - } - - argsJSON := strings.TrimSpace(string(body.Args)) - if argsJSON == "" { - argsJSON = "{}" - } - if !json.Valid([]byte(argsJSON)) { - _ = c.writeError(req.ID, "memory args must be valid JSON") - return - } - - response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ - RequestId: req.ID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_MemoryManage{ - MemoryManage: &gatewayv1.MemoryManageRequest{ - Command: command, - ArgsJson: argsJSON, - }, - }, - }) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - if errResp := response.GetError(); errResp != nil { - _ = c.writeError(req.ID, errResp.GetMessage()) - return - } - - resp := response.GetMemoryManageResp() - if resp == nil { - _ = c.writeError(req.ID, "unexpected agent response") - return - } - - payloadValue, err := unmarshalJSONPayload(resp.GetResultJson()) - if err != nil { - _ = c.writeError(req.ID, err.Error()) - return - } - _ = c.writeResponse(req.ID, payloadValue) -} diff --git a/crates/agent-gateway/internal/server/websocket_payload_bench_test.go b/crates/agent-gateway/internal/server/websocket_payload_bench_test.go deleted file mode 100644 index 16bee6748..000000000 --- a/crates/agent-gateway/internal/server/websocket_payload_bench_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package server - -// v1(protojson 塑形 + JSON 编码)与 v2(proto 直接编码)响应路径的微基准对照, -// 用同一份 history.list 载荷。运行: -// -// go test ./internal/server -bench BenchmarkResponseEncoding -benchmem -run '^$' - -import ( - "encoding/json" - "fmt" - "testing" - - "google.golang.org/protobuf/proto" - - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" - gatewayv2 "github.com/liveagent/agent-gateway/internal/proto/v2" -) - -func benchmarkHistoryListResponse() *gatewayv1.HistoryListResponse { - conversations := make([]*gatewayv1.ConversationSummary, 0, 50) - for i := 0; i < 50; i++ { - conversations = append(conversations, &gatewayv1.ConversationSummary{ - Id: fmt.Sprintf("conversation-%04d", i), - Title: fmt.Sprintf("对话标题 %d:关于 WebSocket 协议迁移的讨论", i), - MessageCount: int32(20 + i), - CreatedAt: 1_752_800_000 + int64(i)*3600, - UpdatedAt: 1_752_800_000 + int64(i)*7200, - ProviderId: "builtin-codex", - Model: "gpt-5.4-mini", - SessionId: fmt.Sprintf("session-%04d", i), - Cwd: "/home/user/projects/example-workspace", - IsPinned: i%7 == 0, - }) - } - return &gatewayv1.HistoryListResponse{ - Conversations: conversations, - TotalCount: 500, - } -} - -// BenchmarkResponseEncodingV1JSON 复刻 v1 出站路径:proto → 手工 map → JSON 信封编码。 -func BenchmarkResponseEncodingV1JSON(b *testing.B) { - resp := benchmarkHistoryListResponse() - b.ReportAllocs() - var lastSize int - for b.Loop() { - conversations := make([]map[string]any, 0, len(resp.GetConversations())) - for _, conversation := range resp.GetConversations() { - conversations = append(conversations, websocketConversationSummaryPayload(conversation)) - } - payload := map[string]any{ - "conversations": conversations, - "total_count": resp.GetTotalCount(), - } - data, err := json.Marshal(websocketEnvelope{ - ID: "bench-1", - Type: "response", - Payload: payload, - }) - if err != nil { - b.Fatal(err) - } - lastSize = len(data) - } - b.ReportMetric(float64(lastSize), "wire-bytes") -} - -// BenchmarkResponseEncodingV2Proto 为 v2 出站路径:信封装帧 + 一次 proto.Marshal。 -func BenchmarkResponseEncodingV2Proto(b *testing.B) { - resp := benchmarkHistoryListResponse() - b.ReportAllocs() - var lastSize int - for b.Loop() { - frame := &gatewayv2.WebServerFrame{ - RequestId: "bench-1", - Payload: &gatewayv2.WebServerFrame_AgentResponse{ - AgentResponse: &gatewayv1.AgentEnvelope{ - RequestId: "bench-1", - Payload: &gatewayv1.AgentEnvelope_HistoryListResp{ - HistoryListResp: resp, - }, - }, - }, - } - data, err := proto.Marshal(frame) - if err != nil { - b.Fatal(err) - } - lastSize = len(data) - } - b.ReportMetric(float64(lastSize), "wire-bytes") -} diff --git a/crates/agent-gateway/internal/server/websocket_payload_test.go b/crates/agent-gateway/internal/server/websocket_payload_test.go deleted file mode 100644 index fb7b96997..000000000 --- a/crates/agent-gateway/internal/server/websocket_payload_test.go +++ /dev/null @@ -1,307 +0,0 @@ -package server - -import ( - "testing" - "time" - - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" - "github.com/liveagent/agent-gateway/internal/session" -) - -func TestWebsocketChatActivityPayloadCarriesRunIdentity(t *testing.T) { - updatedAt := time.UnixMilli(123456) - payload := websocketChatActivityPayload(session.ConversationActivityEvent{ - ConversationID: "conversation-1", - RunID: "run-1", - Running: true, - State: "running", - Workdir: "/workspace", - UpdatedAt: updatedAt, - }) - if payload["conversation_id"] != "conversation-1" || - payload["run_id"] != "run-1" || - payload["running"] != true || - payload["state"] != "running" || - payload["workdir"] != "/workspace" || - payload["updated_at"] != int64(123456) { - t.Fatalf("chat activity payload = %#v", payload) - } - - idle := websocketChatActivityPayload(session.ConversationActivityEvent{ - ConversationID: "conversation-1", - Running: false, - UpdatedAt: updatedAt, - }) - if idle["running"] != false { - t.Fatalf("idle activity payload = %#v", idle) - } - if _, hasRunID := idle["run_id"]; hasRunID { - t.Fatalf("idle activity should omit empty run_id: %#v", idle) - } -} - -func TestWebsocketRunActivityAndSnapshotPayloads(t *testing.T) { - updatedAt := time.UnixMilli(7890) - activity := websocketRunActivityPayload(&session.RunActivity{ - RunID: "run-1", - ClientRequestID: "client-1", - State: "running", - ToolStatus: "Vibing", - ToolStatusIsCompaction: false, - StartedSeq: 17, - UpdatedAt: updatedAt, - }) - if activity["run_id"] != "run-1" || - activity["state"] != "running" || - activity["started_seq"] != int64(17) || - activity["tool_status"] != "Vibing" || - activity["client_request_id"] != "client-1" { - t.Fatalf("run activity payload = %#v", activity) - } - if payload := websocketRunActivityPayload(nil); payload != nil { - t.Fatalf("nil activity payload = %#v, want nil", payload) - } - - snapshot := websocketRunSnapshotPayload(&session.RunSnapshot{ - RunID: "run-1", - Revision: 3, - EntriesJSON: `[{"kind":"assistant"}]`, - ToolStatus: "Compacting", - }) - if snapshot["run_id"] != "run-1" || - snapshot["revision"] != int64(3) || - snapshot["entries_json"] != `[{"kind":"assistant"}]` { - t.Fatalf("run snapshot payload = %#v", snapshot) - } - if payload := websocketRunSnapshotPayload(nil); payload != nil { - t.Fatalf("nil snapshot payload = %#v, want nil", payload) - } -} - -func TestWebsocketChatQueueSnapshotResponsePayload(t *testing.T) { - payload := websocketChatQueueSnapshotResponsePayload(&gatewayv1.ChatQueueEvent{ - ConversationId: "conversation-1", - SnapshotJson: `{"conversationId":"conversation-1","revision":3,"items":[{"id":"queue-1"}]}`, - Revision: 3, - }) - - if payload["accepted"] != true || - payload["snapshot_json"] != `{"conversationId":"conversation-1","revision":3,"items":[{"id":"queue-1"}]}` || - payload["revision"] != uint64(3) || - payload["error_code"] != "" { - t.Fatalf("chat queue snapshot response payload = %#v", payload) - } -} - -func TestWebsocketTerminalPayloadsPreserveOutputOffsets(t *testing.T) { - response := websocketTerminalResponsePayload(&gatewayv1.TerminalResponse{ - Action: "attach", - Output: []byte("uploads\n"), - OutputStartOffset: 8, - OutputEndOffset: 16, - }) - if response["output_start_offset"] != uint64(8) { - t.Fatalf("terminal response output_start_offset = %#v, want 8", response["output_start_offset"]) - } - if response["output_end_offset"] != uint64(16) { - t.Fatalf("terminal response output_end_offset = %#v, want 16", response["output_end_offset"]) - } - - event := websocketTerminalEventPayload(&gatewayv1.TerminalEvent{ - Kind: "output", - SessionId: "terminal-1", - ProjectPathKey: "/workspace/project", - Data: []byte("uploads\n"), - OutputStartOffset: 16, - OutputEndOffset: 24, - }) - if event["output_start_offset"] != uint64(16) { - t.Fatalf("terminal event output_start_offset = %#v, want 16", event["output_start_offset"]) - } - if event["output_end_offset"] != uint64(24) { - t.Fatalf("terminal event output_end_offset = %#v, want 24", event["output_end_offset"]) - } -} - -func TestWebsocketTerminalPayloadsIncludeSshTabsSnapshot(t *testing.T) { - response := websocketTerminalResponsePayload(&gatewayv1.TerminalResponse{ - Action: "ssh_tabs_list", - SshTabs: &gatewayv1.TerminalSshTabsSnapshot{ - ProjectPathKey: "/workspace/project", - Revision: 7, - Tabs: []*gatewayv1.TerminalSshTab{ - { - Id: "bash:ssh-1", - SessionId: "ssh-1", - ProjectPathKey: "/workspace/project", - Kind: "bash", - CreatedAt: 10, - UpdatedAt: 12, - }, - }, - }, - }) - snapshot, ok := response["ssh_tabs"].(map[string]any) - if !ok { - t.Fatalf("ssh_tabs payload missing: %#v", response) - } - if snapshot["project_path_key"] != "/workspace/project" || snapshot["revision"] != uint64(7) { - t.Fatalf("ssh_tabs snapshot = %#v", snapshot) - } - tabs, ok := snapshot["tabs"].([]map[string]any) - if !ok || len(tabs) != 1 { - t.Fatalf("ssh_tabs tabs = %#v", snapshot["tabs"]) - } - if tabs[0]["session_id"] != "ssh-1" || tabs[0]["kind"] != "bash" { - t.Fatalf("ssh_tabs tab = %#v", tabs[0]) - } -} - -func TestWebsocketProtoPayloadPreservesFrontendNumberTypes(t *testing.T) { - payload := websocketConversationSummaryPayload(&gatewayv1.ConversationSummary{ - Id: "conversation-1", - CreatedAt: 42, - UpdatedAt: 84, - MessageCount: 3, - }) - - if got := payload["created_at"]; got != int64(42) { - t.Fatalf("created_at = %#v (%T), want int64(42)", got, got) - } - if got := payload["updated_at"]; got != int64(84) { - t.Fatalf("updated_at = %#v (%T), want int64(84)", got, got) - } - if got := payload["message_count"]; got != int32(3) { - t.Fatalf("message_count = %#v (%T), want int32(3)", got, got) - } -} - -func TestWebsocketProtoPayloadPreservesNilPayloads(t *testing.T) { - if payload := websocketConversationSummaryPayload(nil); payload != nil { - t.Fatalf("conversation nil payload = %#v, want nil", payload) - } - if payload := websocketHistoryShareStatusPayload(nil); payload != nil { - t.Fatalf("history share nil payload = %#v, want nil", payload) - } - if payload := websocketTerminalShellOptionPayload(nil); payload != nil { - t.Fatalf("terminal shell option nil payload = %#v, want nil", payload) - } -} - -func TestWebsocketFsPayloadsUseFrontendFieldNames(t *testing.T) { - list := websocketFsListResponsePayload(&gatewayv1.FsListResponse{ - Path: "src", - HasPath: true, - Depth: 1, - Offset: 2, - MaxResults: 50, - Total: 3, - HasMore: true, - Entries: []*gatewayv1.FsListEntry{ - {Path: "src/components", Kind: "dir", Hidden: true}, - {Path: "src/app.tsx", Kind: "file"}, - }, - }) - if list["path"] != "src" { - t.Fatalf("fs.list path = %#v, want src", list["path"]) - } - if list["maxResults"] != uint32(50) { - t.Fatalf("fs.list maxResults = %#v, want 50", list["maxResults"]) - } - if list["hasMore"] != true { - t.Fatalf("fs.list hasMore = %#v, want true", list["hasMore"]) - } - entries, ok := list["entries"].([]map[string]any) - if !ok || len(entries) != 2 { - t.Fatalf("fs.list entries = %#v, want two entry maps", list["entries"]) - } - if entries[0]["path"] != "src/components" || entries[0]["kind"] != "dir" { - t.Fatalf("fs.list first entry = %#v", entries[0]) - } - if entries[0]["hidden"] != true || entries[1]["hidden"] != false { - t.Fatalf("fs.list hidden flags = %#v", entries) - } - - readEditable := websocketFsReadEditableTextResponsePayload(&gatewayv1.FsReadEditableTextResponse{ - Path: "src/main.ts", - Content: "export {};\n", - MtimeMs: 42, - ContentHash: "hash", - SizeBytes: 11, - TotalLines: 1, - }) - if readEditable["content"] != "export {};\n" { - t.Fatalf("fs.read_editable_text content = %#v", readEditable["content"]) - } - if readEditable["sizeBytes"] != uint64(11) { - t.Fatalf("fs.read_editable_text sizeBytes = %#v, want 11", readEditable["sizeBytes"]) - } - - readWorkspaceImage := websocketFsReadWorkspaceImageResponsePayload(&gatewayv1.FsReadWorkspaceImageResponse{ - Path: "assets/preview.png", - MimeType: "image/png", - Data: "base64", - SizeBytes: 6, - MtimeMs: 42, - ContentHash: "hash", - }) - if readWorkspaceImage["mimeType"] != "image/png" { - t.Fatalf("fs.read_workspace_image mimeType = %#v", readWorkspaceImage["mimeType"]) - } - if readWorkspaceImage["sizeBytes"] != uint64(6) { - t.Fatalf("fs.read_workspace_image sizeBytes = %#v, want 6", readWorkspaceImage["sizeBytes"]) - } - if readWorkspaceImage["contentHash"] != "hash" { - t.Fatalf("fs.read_workspace_image contentHash = %#v", readWorkspaceImage["contentHash"]) - } - - write := websocketFsWriteTextResponsePayload(&gatewayv1.FsWriteTextResponse{ - Path: "src/new.ts", - Mode: "rewrite", - ExistedBefore: false, - BytesWritten: 12, - MtimeMs: 42, - ContentHash: "hash", - TotalLines: 1, - }) - if write["existedBefore"] != false { - t.Fatalf("fs.write_text existedBefore = %#v, want false", write["existedBefore"]) - } - if write["bytesWritten"] != uint64(12) { - t.Fatalf("fs.write_text bytesWritten = %#v, want 12", write["bytesWritten"]) - } - if write["mtimeMs"] != uint64(42) { - t.Fatalf("fs.write_text mtimeMs = %#v, want 42", write["mtimeMs"]) - } - if write["totalLines"] != uint64(1) { - t.Fatalf("fs.write_text totalLines = %#v, want 1", write["totalLines"]) - } - - createDir := websocketFsCreateDirResponsePayload(&gatewayv1.FsCreateDirResponse{ - Path: "src/new-folder", - Kind: "dir", - }) - if createDir["path"] != "src/new-folder" || createDir["kind"] != "dir" { - t.Fatalf("fs.create_dir payload = %#v", createDir) - } - - rename := websocketFsRenameResponsePayload(&gatewayv1.FsRenameResponse{ - FromPath: "src/old.ts", - Path: "src/new.ts", - Kind: "file", - }) - if rename["fromPath"] != "src/old.ts" { - t.Fatalf("fs.rename fromPath = %#v, want src/old.ts", rename["fromPath"]) - } - if rename["path"] != "src/new.ts" || rename["kind"] != "file" { - t.Fatalf("fs.rename payload = %#v", rename) - } - - deletePayload := websocketFsDeleteResponsePayload(&gatewayv1.FsDeleteResponse{ - Path: "src/new.ts", - Kind: "file", - }) - if deletePayload["path"] != "src/new.ts" || deletePayload["kind"] != "file" { - t.Fatalf("fs.delete payload = %#v", deletePayload) - } -} diff --git a/crates/agent-gateway/internal/server/websocket_payloads.go b/crates/agent-gateway/internal/server/websocket_payloads.go deleted file mode 100644 index 66ed56e55..000000000 --- a/crates/agent-gateway/internal/server/websocket_payloads.go +++ /dev/null @@ -1,436 +0,0 @@ -// Deprecated: v1 JSON 协议的处理器/载荷塑形,已被 v2 信封直通(internal/protocol/pbws)取代;仅为旧客户端保留,流量归零后整体删除。 -package server - -import ( - "encoding/json" - "errors" - "reflect" - "strconv" - "strings" - - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" - "github.com/liveagent/agent-gateway/internal/session" - "google.golang.org/protobuf/encoding/protojson" - "google.golang.org/protobuf/proto" - "google.golang.org/protobuf/reflect/protoreflect" -) - -func websocketProtoPayload(message proto.Message, useProtoNames bool) map[string]any { - if message == nil || (reflect.ValueOf(message).Kind() == reflect.Pointer && reflect.ValueOf(message).IsNil()) { - return nil - } - raw, err := protojson.MarshalOptions{ - UseProtoNames: useProtoNames, - EmitUnpopulated: true, - }.Marshal(message) - if err != nil { - return map[string]any{} - } - var payload map[string]any - if err := json.Unmarshal(raw, &payload); err != nil { - return map[string]any{} - } - coerceProtoJSONNumbers(payload, message.ProtoReflect().Descriptor(), useProtoNames) - return payload -} - -func coerceProtoJSONNumbers(payload map[string]any, descriptor protoreflect.MessageDescriptor, useProtoNames bool) { - if payload == nil || descriptor == nil { - return - } - fields := descriptor.Fields() - for i := 0; i < fields.Len(); i++ { - field := fields.Get(i) - key := field.JSONName() - if useProtoNames { - key = field.TextName() - } - value, ok := payload[key] - if !ok { - continue - } - payload[key] = coerceProtoJSONField(value, field, useProtoNames) - } -} - -func coerceProtoJSONField(value any, field protoreflect.FieldDescriptor, useProtoNames bool) any { - if field == nil || value == nil { - return value - } - if field.IsList() { - items, ok := value.([]any) - if !ok { - return value - } - for i, item := range items { - items[i] = coerceProtoJSONScalarOrMessage(item, field, useProtoNames) - } - return items - } - return coerceProtoJSONScalarOrMessage(value, field, useProtoNames) -} - -func coerceProtoJSONScalarOrMessage(value any, field protoreflect.FieldDescriptor, useProtoNames bool) any { - if field.Kind() == protoreflect.MessageKind || field.Kind() == protoreflect.GroupKind { - if nested, ok := value.(map[string]any); ok { - coerceProtoJSONNumbers(nested, field.Message(), useProtoNames) - } - return value - } - switch field.Kind() { - case protoreflect.Int32Kind, protoreflect.Sint32Kind, protoreflect.Sfixed32Kind: - if number, ok := value.(float64); ok { - return int32(number) - } - case protoreflect.Uint32Kind, protoreflect.Fixed32Kind: - if number, ok := value.(float64); ok { - return uint32(number) - } - case protoreflect.Int64Kind, protoreflect.Sint64Kind, protoreflect.Sfixed64Kind: - if text, ok := value.(string); ok { - if parsed, err := strconv.ParseInt(text, 10, 64); err == nil { - return parsed - } - } - case protoreflect.Uint64Kind, protoreflect.Fixed64Kind: - if text, ok := value.(string); ok { - if parsed, err := strconv.ParseUint(text, 10, 64); err == nil { - return parsed - } - } - } - return value -} - -func websocketConversationSummaryPayload(conversation *gatewayv1.ConversationSummary) map[string]any { - return websocketProtoPayload(conversation, true) -} - -func websocketHistoryShareStatusPayload(share *gatewayv1.HistoryShareStatus) map[string]any { - return websocketProtoPayload(share, true) -} - -func websocketHistorySyncPayload(event *gatewayv1.HistorySyncEvent) map[string]any { - payload := map[string]any{ - "kind": strings.TrimSpace(event.GetKind()), - "conversation_id": strings.TrimSpace(event.GetConversationId()), - } - if conversation := event.GetConversation(); conversation != nil { - payload["conversation"] = websocketConversationSummaryPayload(conversation) - } - return payload -} - -func websocketChatActivityPayload(event session.ConversationActivityEvent) map[string]any { - payload := map[string]any{ - "conversation_id": event.ConversationID, - "running": event.Running, - "updated_at": event.UpdatedAt.UnixMilli(), - } - if event.RunID != "" { - payload["run_id"] = event.RunID - } - if event.ClientRequestID != "" { - payload["client_request_id"] = event.ClientRequestID - } - if event.State != "" { - payload["state"] = event.State - } - if event.Workdir != "" { - payload["workdir"] = event.Workdir - } - return payload -} - -// websocketRunningConversationsPayload is the wire shape for running-run -// summaries, shared by history.list and chat.activities. -func websocketRunningConversationsPayload(activities []session.RunActivity) []map[string]any { - runningConversations := make([]map[string]any, 0, len(activities)) - for _, activity := range activities { - runningConversations = append(runningConversations, map[string]any{ - "conversation_id": activity.ConversationID, - "run_id": activity.RunID, - "state": activity.State, - "cwd": activity.Workdir, - "updated_at": activity.UpdatedAt.UnixMilli(), - }) - } - return runningConversations -} - -func websocketRunActivityPayload(activity *session.RunActivity) map[string]any { - if activity == nil { - return nil - } - payload := map[string]any{ - "run_id": activity.RunID, - "state": activity.State, - "started_seq": activity.StartedSeq, - "updated_at": activity.UpdatedAt.UnixMilli(), - } - if activity.ToolStatus != "" { - payload["tool_status"] = activity.ToolStatus - payload["tool_status_is_compaction"] = activity.ToolStatusIsCompaction - } - if activity.ClientRequestID != "" { - payload["client_request_id"] = activity.ClientRequestID - } - return payload -} - -func websocketRunSnapshotPayload(snapshot *session.RunSnapshot) map[string]any { - if snapshot == nil { - return nil - } - return map[string]any{ - "run_id": snapshot.RunID, - "revision": snapshot.Revision, - "entries_json": snapshot.EntriesJSON, - "tool_status": snapshot.ToolStatus, - "tool_status_is_compaction": snapshot.ToolStatusIsCompaction, - "as_of_seq": snapshot.AsOfSeq, - } -} - -func websocketSettingsJSONPayload(raw string) (map[string]any, error) { - trimmed := strings.TrimSpace(raw) - if trimmed == "" { - return map[string]any{}, nil - } - - var payload map[string]any - if err := json.Unmarshal([]byte(trimmed), &payload); err != nil { - return nil, errors.New("gateway settings payload is not valid JSON") - } - if payload == nil { - return map[string]any{}, nil - } - return payload, nil -} - -func websocketChatQueueEventPayload(event *gatewayv1.ChatQueueEvent) map[string]any { - return map[string]any{ - "conversation_id": strings.TrimSpace(event.GetConversationId()), - "snapshot_json": strings.TrimSpace(event.GetSnapshotJson()), - "revision": event.GetRevision(), - } -} - -func websocketTerminalSessionPayload(session *gatewayv1.TerminalSession) map[string]any { - if session == nil { - return nil - } - kind := terminalSessionKind(session) - payload := map[string]any{ - "id": strings.TrimSpace(session.GetId()), - "project_path_key": strings.TrimSpace(session.GetProjectPathKey()), - "cwd": strings.TrimSpace(session.GetCwd()), - "shell": strings.TrimSpace(session.GetShell()), - "title": strings.TrimSpace(session.GetTitle()), - "kind": kind, - "pid": session.GetPid(), - "cols": session.GetCols(), - "rows": session.GetRows(), - "created_at": session.GetCreatedAt(), - "updated_at": session.GetUpdatedAt(), - "finished_at": session.GetFinishedAt(), - "exit_code": session.GetExitCode(), - "running": session.GetRunning(), - } - if session.GetPid() == 0 { - payload["pid"] = nil - } - if session.GetFinishedAt() == 0 { - payload["finished_at"] = nil - } - if kind == "ssh" { - payload["pid"] = nil - } - if ssh := session.GetSsh(); ssh != nil { - payload["ssh"] = map[string]any{ - "host_id": strings.TrimSpace(ssh.GetHostId()), - "host_name": strings.TrimSpace(ssh.GetHostName()), - "username": strings.TrimSpace(ssh.GetUsername()), - "host": strings.TrimSpace(ssh.GetHost()), - "port": ssh.GetPort(), - "auth_type": strings.TrimSpace(ssh.GetAuthType()), - "status": strings.TrimSpace(ssh.GetStatus()), - "reconnect_attempt": ssh.GetReconnectAttempt(), - "reconnect_max_attempts": ssh.GetReconnectMaxAttempts(), - "sftp_enabled": ssh.GetSftpEnabled(), - "sftpEnabled": ssh.GetSftpEnabled(), - } - } - return payload -} - -func terminalSessionKind(session *gatewayv1.TerminalSession) string { - kind := strings.TrimSpace(session.GetKind()) - if kind == "ssh" { - return "ssh" - } - return "local" -} - -func websocketTerminalShellOptionPayload(option *gatewayv1.TerminalShellOption) map[string]any { - payload := websocketProtoPayload(option, false) - if payload == nil { - return nil - } - payload["id"] = strings.TrimSpace(option.GetId()) - payload["label"] = strings.TrimSpace(option.GetLabel()) - payload["command"] = strings.TrimSpace(option.GetCommand()) - return payload -} - -func websocketTerminalSshTabPayload(tab *gatewayv1.TerminalSshTab) map[string]any { - if tab == nil { - return nil - } - return map[string]any{ - "id": strings.TrimSpace(tab.GetId()), - "session_id": strings.TrimSpace(tab.GetSessionId()), - "project_path_key": strings.TrimSpace(tab.GetProjectPathKey()), - "kind": strings.TrimSpace(tab.GetKind()), - "created_at": tab.GetCreatedAt(), - "updated_at": tab.GetUpdatedAt(), - } -} - -func websocketTerminalSshTabsPayload(snapshot *gatewayv1.TerminalSshTabsSnapshot) map[string]any { - if snapshot == nil { - return nil - } - tabs := make([]map[string]any, 0, len(snapshot.GetTabs())) - for _, tab := range snapshot.GetTabs() { - if payload := websocketTerminalSshTabPayload(tab); payload != nil { - tabs = append(tabs, payload) - } - } - return map[string]any{ - "project_path_key": strings.TrimSpace(snapshot.GetProjectPathKey()), - "tabs": tabs, - "revision": snapshot.GetRevision(), - } -} - -func websocketTerminalResponsePayload(resp *gatewayv1.TerminalResponse) map[string]any { - sessions := make([]map[string]any, 0, len(resp.GetSessions())) - for _, session := range resp.GetSessions() { - if payload := websocketTerminalSessionPayload(session); payload != nil { - sessions = append(sessions, payload) - } - } - shellOptions := make([]map[string]any, 0, len(resp.GetShellOptions())) - for _, option := range resp.GetShellOptions() { - if payload := websocketTerminalShellOptionPayload(option); payload != nil { - shellOptions = append(shellOptions, payload) - } - } - payload := map[string]any{ - "action": strings.TrimSpace(resp.GetAction()), - "sessions": sessions, - "output": string(resp.GetOutput()), - "output_bytes": resp.GetOutput(), - "truncated": resp.GetTruncated(), - "shell_options": shellOptions, - "default_shell": resp.GetDefaultShell(), - } - if resp.GetOutputStartOffset() != 0 || resp.GetOutputEndOffset() != 0 || len(resp.GetOutput()) > 0 { - payload["output_start_offset"] = resp.GetOutputStartOffset() - payload["output_end_offset"] = resp.GetOutputEndOffset() - } - if resp.GetLatencyMs() > 0 { - payload["latency_ms"] = resp.GetLatencyMs() - } - if session := websocketTerminalSessionPayload(resp.GetSession()); session != nil { - payload["session"] = session - } - if prompt := resp.GetSshPrompt(); prompt != nil { - payload["ssh_prompt"] = map[string]any{ - "id": strings.TrimSpace(prompt.GetId()), - "kind": strings.TrimSpace(prompt.GetKind()), - "host_id": strings.TrimSpace(prompt.GetHostId()), - "host_name": strings.TrimSpace(prompt.GetHostName()), - "host": strings.TrimSpace(prompt.GetHost()), - "port": prompt.GetPort(), - "message": strings.TrimSpace(prompt.GetMessage()), - "fingerprint_sha256": strings.TrimSpace(prompt.GetFingerprintSha256()), - "key_type": strings.TrimSpace(prompt.GetKeyType()), - "answer_echo": prompt.GetAnswerEcho(), - } - } - if sshTabs := websocketTerminalSshTabsPayload(resp.GetSshTabs()); sshTabs != nil { - payload["ssh_tabs"] = sshTabs - } - return payload -} - -func websocketTerminalEventPayload(event *gatewayv1.TerminalEvent) map[string]any { - payload := map[string]any{ - "kind": strings.TrimSpace(event.GetKind()), - "session_id": strings.TrimSpace(event.GetSessionId()), - "project_path_key": strings.TrimSpace(event.GetProjectPathKey()), - } - if len(event.GetData()) > 0 { - payload["data"] = string(event.GetData()) - payload["data_bytes"] = event.GetData() - } - if event.GetOutputStartOffset() != 0 || event.GetOutputEndOffset() != 0 || len(event.GetData()) > 0 { - payload["output_start_offset"] = event.GetOutputStartOffset() - payload["output_end_offset"] = event.GetOutputEndOffset() - } - if session := websocketTerminalSessionPayload(event.GetSession()); session != nil { - payload["session"] = session - } - if sshTabs := websocketTerminalSshTabsPayload(event.GetSshTabs()); sshTabs != nil { - payload["ssh_tabs"] = sshTabs - } - return payload -} - -func unmarshalJSONPayload(raw string) (any, error) { - trimmed := strings.TrimSpace(raw) - if trimmed == "" { - return map[string]any{}, nil - } - var payload any - if err := json.Unmarshal([]byte(trimmed), &payload); err != nil { - return nil, errors.New("response is not valid JSON") - } - if payload == nil { - return map[string]any{}, nil - } - return payload, nil -} - -func websocketRawPayloadJSON(raw json.RawMessage) (string, error) { - trimmed := strings.TrimSpace(string(raw)) - if trimmed == "" { - return "{}", nil - } - - var payload map[string]any - if err := json.Unmarshal([]byte(trimmed), &payload); err != nil { - return "", errors.New("invalid settings.update payload") - } - if payload == nil { - return "{}", nil - } - - normalized, err := json.Marshal(payload) - if err != nil { - return "", errors.New("invalid settings.update payload") - } - return string(normalized), nil -} - -func websocketOptionalUint32(value *int, field string) (uint32, error) { - if value == nil { - return 0, nil - } - if *value < 0 { - return 0, errors.New(field + " must be >= 0") - } - return uint32(*value), nil -} diff --git a/crates/agent-gateway/internal/server/websocket_process_handlers.go b/crates/agent-gateway/internal/server/websocket_process_handlers.go deleted file mode 100644 index e8abf03ed..000000000 --- a/crates/agent-gateway/internal/server/websocket_process_handlers.go +++ /dev/null @@ -1,169 +0,0 @@ -// Deprecated: v1 JSON 协议的处理器/载荷塑形,已被 v2 信封直通(internal/protocol/pbws)取代;仅为旧客户端保留,流量归零后整体删除。 -package server - -import ( - "errors" - "strings" - "time" - - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" -) - -type websocketProcessRequestPayload struct { - ProcessID string `json:"process_id"` - MaxBytes uint32 `json:"max_bytes"` -} - -// handleProcessSnapshot answers from the gateway cache so the panel can show -// the last known state even while the agent is offline. -func (c *websocketConnection) handleProcessSnapshot(req websocketRequest) { - _ = c.writeResponse( - req.ID, - websocketManagedProcessPayload(c.sm.ManagedProcessSnapshotCached(), c.sm.IsOnline()), - ) -} - -func (c *websocketConnection) handleProcessStop(req websocketRequest) { - c.handleProcessRequest(req, "stop") -} - -func (c *websocketConnection) handleProcessReadLog(req websocketRequest) { - c.handleProcessRequest(req, "read_log") -} - -func (c *websocketConnection) handleProcessClear(req websocketRequest) { - c.handleProcessRequest(req, "clear") -} - -// handleProcessRequest forwards a panel operation to the agent (the process -// owner) and relays its verdict. State reaches every client through the -// process.state broadcast that the operation triggers on the agent. -func (c *websocketConnection) handleProcessRequest(req websocketRequest, action string) { - var body websocketProcessRequestPayload - if err := decodeWebSocketPayload(req.Payload, &body); err != nil { - _ = c.writeError(req.ID, "invalid process."+action+" payload") - return - } - processID := strings.TrimSpace(body.ProcessID) - if processID == "" && action != "clear" { - _ = c.writeError(req.ID, "process_id is required") - return - } - - response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ - RequestId: req.ID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_ManagedProcessRequest{ - ManagedProcessRequest: &gatewayv1.ManagedProcessRequest{ - Action: action, - ProcessId: processID, - MaxBytes: body.MaxBytes, - }, - }, - }) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - if errResp := response.GetError(); errResp != nil { - _ = c.writeError(req.ID, errResp.GetMessage()) - return - } - result := response.GetManagedProcessResponse() - if result == nil { - _ = c.writeError(req.ID, "unexpected agent response") - return - } - - payload := map[string]any{"action": result.GetAction()} - switch action { - case "stop": - payload["stopped"] = result.GetStopped() - payload["state"] = websocketManagedProcessPayload(result.GetSnapshot(), c.sm.IsOnline()) - case "clear": - payload["state"] = websocketManagedProcessPayload(result.GetSnapshot(), c.sm.IsOnline()) - case "read_log": - payload["log_content"] = result.GetLogContent() - payload["log_path"] = result.GetLogPath() - payload["log_truncated"] = result.GetLogTruncated() - } - _ = c.writeResponse(req.ID, payload) -} - -func (c *websocketConnection) startManagedProcessStateForwarder() { - if c.managedProcessEvents != nil || c.managedProcessEventsCleanup != nil { - return - } - - managedProcessEvents, cleanup := c.sm.SubscribeManagedProcessState() - c.managedProcessEvents = managedProcessEvents - c.managedProcessEventsCleanup = cleanup - - go func() { - for { - select { - case <-c.done: - return - case snapshot, ok := <-managedProcessEvents: - if !ok { - return - } - payload := websocketManagedProcessPayload(snapshot, c.sm.IsOnline()) - if err := c.writeEvent("process.state", payload); err != nil { - if errors.Is(err, errWriteQueueFull) { - continue - } - return - } - } - } - }() -} - -func (c *websocketConnection) replayManagedProcessSnapshot() { - _ = c.writeEvent( - "process.state", - websocketManagedProcessPayload(c.sm.ManagedProcessSnapshotCached(), c.sm.IsOnline()), - ) -} - -func websocketManagedProcessPayload( - snapshot *gatewayv1.ManagedProcessSnapshot, - agentOnline bool, -) map[string]any { - processes := []map[string]any{} - revision := uint64(0) - if snapshot != nil { - revision = snapshot.GetRevision() - for _, record := range snapshot.GetProcesses() { - if record == nil { - continue - } - entry := map[string]any{ - "id": record.GetId(), - "label": record.GetLabel(), - "command": record.GetCommand(), - "cwd": record.GetCwd(), - "shell": record.GetShell(), - "pid": record.GetPid(), - "log_path": record.GetLogPath(), - "started_at": record.GetStartedAt(), - "running": record.GetRunning(), - "isolated": record.GetIsolated(), - "restored": record.GetRestored(), - } - if record.FinishedAt != nil { - entry["finished_at"] = record.GetFinishedAt() - } - if record.ExitCode != nil { - entry["exit_code"] = record.GetExitCode() - } - processes = append(processes, entry) - } - } - return map[string]any{ - "revision": revision, - "agent_online": agentOnline, - "processes": processes, - } -} diff --git a/crates/agent-gateway/internal/server/websocket_provider_handlers.go b/crates/agent-gateway/internal/server/websocket_provider_handlers.go deleted file mode 100644 index 024bebf22..000000000 --- a/crates/agent-gateway/internal/server/websocket_provider_handlers.go +++ /dev/null @@ -1,89 +0,0 @@ -// Deprecated: v1 JSON 协议的处理器/载荷塑形,已被 v2 信封直通(internal/protocol/pbws)取代;仅为旧客户端保留,流量归零后整体删除。 -package server - -import ( - "encoding/json" - "strings" - "time" - - "github.com/liveagent/agent-gateway/internal/handler" - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" -) - -func (c *websocketConnection) handleProviderList(req websocketRequest) { - response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ - RequestId: req.ID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_ProviderList{ - ProviderList: &gatewayv1.ProviderListRequest{}, - }, - }) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - if errResp := response.GetError(); errResp != nil { - _ = c.writeError(req.ID, errResp.GetMessage()) - return - } - - resp := response.GetProviderListResp() - if resp == nil { - _ = c.writeError(req.ID, "unexpected agent response") - return - } - - var payload any - raw := strings.TrimSpace(resp.GetProvidersJson()) - if raw == "" { - payload = []any{} - } else if err := json.Unmarshal([]byte(raw), &payload); err != nil { - _ = c.writeError(req.ID, "provider list response is not valid JSON") - return - } - - _ = c.writeResponse(req.ID, payload) -} - -// handleProviderModels always forwards the fetch to the connected desktop -// agent: provider endpoints may only be reachable from the agent's machine -// (e.g. localhost services), so the gateway never fetches models itself. -func (c *websocketConnection) handleProviderModels(req websocketRequest) { - var body handler.ProviderModelsRequestBody - if err := decodeWebSocketPayload(req.Payload, &body); err != nil { - _ = c.writeError(req.ID, "invalid provider.models payload") - return - } - - response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ - RequestId: req.ID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_ProviderModels{ - ProviderModels: &gatewayv1.ProviderModelsRequest{ - ProviderType: strings.TrimSpace(body.Type), - BaseUrl: strings.TrimSpace(body.BaseURL), - ApiKey: strings.TrimSpace(body.APIKey), - UseSystemProxy: body.UseSystemProxy, - }, - }, - }) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - if errResp := response.GetError(); errResp != nil { - _ = c.writeError(req.ID, errResp.GetMessage()) - return - } - providerModelsResp := response.GetProviderModelsResp() - if providerModelsResp == nil { - _ = c.writeError(req.ID, "unexpected agent response") - return - } - var payload any - if err := json.Unmarshal([]byte(providerModelsResp.GetModelsJson()), &payload); err != nil { - _ = c.writeError(req.ID, "provider model response is not valid JSON") - return - } - _ = c.writeResponse(req.ID, payload) -} diff --git a/crates/agent-gateway/internal/server/websocket_roundtrip.go b/crates/agent-gateway/internal/server/websocket_roundtrip.go deleted file mode 100644 index 63c2a42b7..000000000 --- a/crates/agent-gateway/internal/server/websocket_roundtrip.go +++ /dev/null @@ -1,54 +0,0 @@ -// Deprecated: v1 JSON 协议的处理器/载荷塑形,已被 v2 信封直通(internal/protocol/pbws)取代;仅为旧客户端保留,流量归零后整体删除。 -package server - -import ( - "context" - "encoding/json" - "errors" - "strings" - - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" - "github.com/liveagent/agent-gateway/internal/session" -) - -func decodeWebSocketPayload(raw json.RawMessage, target any) error { - if len(raw) == 0 { - return json.Unmarshal([]byte("{}"), target) - } - decoder := json.NewDecoder(strings.NewReader(string(raw))) - decoder.DisallowUnknownFields() - return decoder.Decode(target) -} - -func awaitAgentUnaryResponse( - ctx context.Context, - sm *session.Manager, - requestID string, - envelope *gatewayv1.GatewayEnvelope, -) (*gatewayv1.AgentEnvelope, error) { - return sm.AwaitUnaryResponse(ctx, requestID, envelope) -} - -func websocketErrorMessage(err error) string { - if err == nil { - return "request failed" - } - if errors.Is(err, context.DeadlineExceeded) { - return "request timed out" - } - if errors.Is(err, context.Canceled) { - return "request canceled" - } - if errors.Is(err, session.ErrAgentOffline) { - return "agent offline" - } - return err.Error() -} - -func requireTrimmedWebSocketString(value string, field string) (string, error) { - trimmed := strings.TrimSpace(value) - if trimmed == "" { - return "", errors.New(field + " is required") - } - return trimmed, nil -} diff --git a/crates/agent-gateway/internal/server/websocket_routes.go b/crates/agent-gateway/internal/server/websocket_routes.go deleted file mode 100644 index f26091282..000000000 --- a/crates/agent-gateway/internal/server/websocket_routes.go +++ /dev/null @@ -1,119 +0,0 @@ -package server - -// websocketRequestHandler is a v1 JSON protocol request handler. -// -// Deprecated: v1 JSON 协议的分发机制,已被 v2 的 GatewayEnvelope 直通 + 白名单校验(internal/protocol/pbws/guard.go)取代。 -type websocketRequestHandler func(*websocketConnection, websocketRequest) - -// websocketRequestHandlers maps v1 op strings to handlers. -// -// Deprecated: 见 websocketRequestHandler;随 v1 一并删除。 -var websocketRequestHandlers = map[string]websocketRequestHandler{ - "status.get": func(c *websocketConnection, req websocketRequest) { - _ = c.writeResponse(req.ID, c.sm.Status()) - }, - "fs.roots": (*websocketConnection).handleFsRoots, - "fs.list_dirs": (*websocketConnection).handleFsListDirs, - "fs.create_project_folder": (*websocketConnection).handleFsCreateProjectFolder, - "fs.list": (*websocketConnection).handleFsList, - "fs.read_editable_text": (*websocketConnection).handleFsReadEditableText, - "fs.read_workspace_image": (*websocketConnection).handleFsReadWorkspaceImage, - "fs.write_text": (*websocketConnection).handleFsWriteText, - "fs.create_dir": (*websocketConnection).handleFsCreateDir, - "fs.rename": (*websocketConnection).handleFsRename, - "fs.delete": (*websocketConnection).handleFsDelete, - "history.list": (*websocketConnection).handleHistoryList, - "history.workdirs": (*websocketConnection).handleHistoryWorkdirs, - "history.shared_list": (*websocketConnection).handleHistorySharedList, - "history.get": (*websocketConnection).handleHistoryGet, - "history.prefix": (*websocketConnection).handleHistoryPrefix, - "history.rename": (*websocketConnection).handleHistoryRename, - "history.branch": (*websocketConnection).handleHistoryBranch, - "history.pin": (*websocketConnection).handleHistoryPin, - "history.share.get": (*websocketConnection).handleHistoryShareGet, - "history.share.set": (*websocketConnection).handleHistoryShareSet, - "history.delete": (*websocketConnection).handleHistoryDelete, - "providers.list": (*websocketConnection).handleProviderList, - "settings.get": (*websocketConnection).handleSettingsGet, - "settings.update": (*websocketConnection).handleSettingsUpdate, - "settings.ssh_known_host.reset": (*websocketConnection).handleSettingsResetSshKnownHost, - "skills.list": (*websocketConnection).handleSkillFilesList, - "mentions.list": (*websocketConnection).handleFileMentionList, - "skills.read-metadata": (*websocketConnection).handleSkillMetadataRead, - "skills.read-text": (*websocketConnection).handleSkillTextRead, - "skills.manage": (*websocketConnection).handleSkillManage, - "files.preview": (*websocketConnection).handleUploadedImagePreview, - "memory.manage": (*websocketConnection).handleMemoryManage, - "terminal.shell_options": (*websocketConnection).handleTerminalRequest, - "terminal.list": (*websocketConnection).handleTerminalRequest, - "terminal.create": (*websocketConnection).handleTerminalRequest, - "terminal.create_ssh": (*websocketConnection).handleTerminalRequest, - "terminal.answer_ssh_prompt": (*websocketConnection).handleTerminalRequest, - "terminal.cancel_ssh_prompt": (*websocketConnection).handleTerminalRequest, - "terminal.ssh_latency": (*websocketConnection).handleTerminalRequest, - "terminal.ssh_tabs_list": (*websocketConnection).handleTerminalRequest, - "terminal.ssh_tab_open": (*websocketConnection).handleTerminalRequest, - "terminal.ssh_tab_close": (*websocketConnection).handleTerminalRequest, - "terminal.rename": (*websocketConnection).handleTerminalRequest, - "terminal.close": (*websocketConnection).handleTerminalRequest, - "terminal.close_project": (*websocketConnection).handleTerminalRequest, - "sftp.list": (*websocketConnection).handleSftpRequest, - "sftp.stat": (*websocketConnection).handleSftpRequest, - "sftp.mkdir": (*websocketConnection).handleSftpRequest, - "sftp.rename": (*websocketConnection).handleSftpRequest, - "sftp.delete": (*websocketConnection).handleSftpRequest, - "sftp.transfer": (*websocketConnection).handleSftpRequest, - "sftp.cancel": (*websocketConnection).handleSftpRequest, - "tunnel.create": (*websocketConnection).handleTunnelCreate, - "tunnel.update": (*websocketConnection).handleTunnelUpdate, - "tunnel.close": (*websocketConnection).handleTunnelClose, - "tunnel.check": (*websocketConnection).handleTunnelCheck, - "process.snapshot": (*websocketConnection).handleProcessSnapshot, - "process.stop": (*websocketConnection).handleProcessStop, - "process.read_log": (*websocketConnection).handleProcessReadLog, - "process.clear": (*websocketConnection).handleProcessClear, - "git.status": (*websocketConnection).handleGitRequest, - "git.branches": (*websocketConnection).handleGitRequest, - "git.init": (*websocketConnection).handleGitRequest, - "git.switch_branch": (*websocketConnection).handleGitRequest, - "git.create_branch": (*websocketConnection).handleGitRequest, - "git.diff": (*websocketConnection).handleGitRequest, - "git.log": (*websocketConnection).handleGitRequest, - "git.commit_details": (*websocketConnection).handleGitRequest, - "git.compare_commit_with_remote": (*websocketConnection).handleGitRequest, - "git.commit_diff": (*websocketConnection).handleGitRequest, - "git.stage": (*websocketConnection).handleGitRequest, - "git.stage_all": (*websocketConnection).handleGitRequest, - "git.unstage": (*websocketConnection).handleGitRequest, - "git.unstage_all": (*websocketConnection).handleGitRequest, - "git.discard": (*websocketConnection).handleGitRequest, - "git.discard_all": (*websocketConnection).handleGitRequest, - "git.add_to_gitignore": (*websocketConnection).handleGitRequest, - "git.commit": (*websocketConnection).handleGitRequest, - "git.fetch": (*websocketConnection).handleGitRequest, - "git.pull": (*websocketConnection).handleGitRequest, - "git.set_remote": (*websocketConnection).handleGitRequest, - "git.push": (*websocketConnection).handleGitRequest, - "git.delete_branch": (*websocketConnection).handleGitRequest, - "git.rename_branch": (*websocketConnection).handleGitRequest, - "git.stash_push": (*websocketConnection).handleGitRequest, - "git.stash_pop": (*websocketConnection).handleGitRequest, - "cron.manage": (*websocketConnection).handleCronManage, - "provider.models": (*websocketConnection).handleProviderModels, - "chat.subscribe": (*websocketConnection).handleChatSubscribe, - "chat.unsubscribe": (*websocketConnection).handleChatUnsubscribe, - "workspace.subscribe": (*websocketConnection).handleWorkspaceSubscribe, - "workspace.unsubscribe": (*websocketConnection).handleWorkspaceUnsubscribe, - "chat.activities": (*websocketConnection).handleChatActivities, - "chat.prepare": (*websocketConnection).handleChatPrepare, - "chat.command": (*websocketConnection).handleChatCommand, - "chat.cancel": (*websocketConnection).handleChatCancel, - "chat_queue.get": (*websocketConnection).handleChatQueueRequest, - "chat_queue.get_item": (*websocketConnection).handleChatQueueRequest, - "chat_queue.run_now": (*websocketConnection).handleChatQueueRequest, - "chat_queue.move": (*websocketConnection).handleChatQueueRequest, - "chat_queue.remove": (*websocketConnection).handleChatQueueRequest, - "chat_queue.edit_begin": (*websocketConnection).handleChatQueueRequest, - "chat_queue.edit_commit": (*websocketConnection).handleChatQueueRequest, - "chat_queue.edit_cancel": (*websocketConnection).handleChatQueueRequest, -} diff --git a/crates/agent-gateway/internal/server/websocket_routes_test.go b/crates/agent-gateway/internal/server/websocket_routes_test.go deleted file mode 100644 index 50d902924..000000000 --- a/crates/agent-gateway/internal/server/websocket_routes_test.go +++ /dev/null @@ -1,173 +0,0 @@ -package server - -import ( - "testing" -) - -func TestWebsocketRequestHandlersCoverKnownProtocolTypes(t *testing.T) { - t.Parallel() - - expectedTypes := []string{ - "status.get", - "fs.roots", - "fs.list_dirs", - "fs.create_project_folder", - "fs.list", - "fs.read_editable_text", - "fs.read_workspace_image", - "fs.write_text", - "fs.create_dir", - "fs.rename", - "fs.delete", - "history.list", - "history.workdirs", - "history.shared_list", - "history.get", - "history.prefix", - "history.rename", - "history.branch", - "history.pin", - "history.share.get", - "history.share.set", - "history.delete", - "providers.list", - "settings.get", - "settings.update", - "settings.ssh_known_host.reset", - "skills.list", - "mentions.list", - "skills.read-metadata", - "skills.read-text", - "skills.manage", - "files.preview", - "memory.manage", - "terminal.shell_options", - "terminal.list", - "terminal.create", - "terminal.create_ssh", - "terminal.answer_ssh_prompt", - "terminal.cancel_ssh_prompt", - "terminal.ssh_latency", - "terminal.ssh_tabs_list", - "terminal.ssh_tab_open", - "terminal.ssh_tab_close", - "terminal.rename", - "terminal.close", - "terminal.close_project", - "sftp.list", - "sftp.stat", - "sftp.mkdir", - "sftp.rename", - "sftp.delete", - "sftp.transfer", - "sftp.cancel", - "tunnel.create", - "tunnel.update", - "tunnel.close", - "tunnel.check", - "process.snapshot", - "process.stop", - "process.read_log", - "process.clear", - "git.status", - "git.branches", - "git.init", - "git.switch_branch", - "git.create_branch", - "git.diff", - "git.log", - "git.commit_details", - "git.compare_commit_with_remote", - "git.commit_diff", - "git.stage", - "git.stage_all", - "git.unstage", - "git.unstage_all", - "git.discard", - "git.discard_all", - "git.add_to_gitignore", - "git.commit", - "git.fetch", - "git.pull", - "git.set_remote", - "git.push", - "git.delete_branch", - "git.rename_branch", - "git.stash_push", - "git.stash_pop", - "cron.manage", - "provider.models", - "chat.subscribe", - "chat.unsubscribe", - "workspace.subscribe", - "workspace.unsubscribe", - "chat.activities", - "chat.prepare", - "chat.command", - "chat.cancel", - "chat_queue.get", - "chat_queue.get_item", - "chat_queue.run_now", - "chat_queue.move", - "chat_queue.remove", - "chat_queue.edit_begin", - "chat_queue.edit_commit", - "chat_queue.edit_cancel", - } - - for _, requestType := range expectedTypes { - if websocketRequestHandlers[requestType] == nil { - t.Fatalf("websocketRequestHandlers[%q] is missing", requestType) - } - } - if got := len(websocketRequestHandlers); got != len(expectedTypes) { - t.Fatalf("websocketRequestHandlers has %d entries, want %d", got, len(expectedTypes)) - } - - for _, removedType := range []string{ - "history.truncate", - "chat.start", - "chat.resume", - "chat.attach", - "chat.detach", - "terminal.attach", - "terminal.input", - "terminal.resize", - "terminal.detach", - } { - if websocketRequestHandlers[removedType] != nil { - t.Fatalf("websocketRequestHandlers[%q] should be removed; terminal bytes use /ws/terminal", removedType) - } - } -} - -func TestGitActionIsWrite(t *testing.T) { - t.Parallel() - - for _, action := range []string{"delete_branch", "rename_branch", "stash_push", "stash_pop"} { - if !gitActionIsWrite(action) { - t.Fatalf("gitActionIsWrite(%q) = false, want true", action) - } - } - for _, action := range []string{"status", "branches", "diff", "log"} { - if gitActionIsWrite(action) { - t.Fatalf("gitActionIsWrite(%q) = true, want false", action) - } - } -} - -func TestDecodeWebSocketPayloadRejectsUnknownFields(t *testing.T) { - t.Parallel() - - var empty struct{} - if err := decodeWebSocketPayload(nil, &empty); err != nil { - t.Fatalf("decode empty payload: %v", err) - } - - var payload struct { - Known string `json:"known"` - } - if err := decodeWebSocketPayload([]byte(`{"known":"ok","unknown":true}`), &payload); err == nil { - t.Fatal("expected unknown payload field to be rejected") - } -} diff --git a/crates/agent-gateway/internal/server/websocket_settings_handlers.go b/crates/agent-gateway/internal/server/websocket_settings_handlers.go deleted file mode 100644 index 9a4317037..000000000 --- a/crates/agent-gateway/internal/server/websocket_settings_handlers.go +++ /dev/null @@ -1,147 +0,0 @@ -// Deprecated: v1 JSON 协议的处理器/载荷塑形,已被 v2 信封直通(internal/protocol/pbws)取代;仅为旧客户端保留,流量归零后整体删除。 -package server - -import ( - "encoding/json" - "strings" - "time" - - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" -) - -func (c *websocketConnection) handleSettingsGet(req websocketRequest) { - response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ - RequestId: req.ID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_SettingsGet{ - SettingsGet: &gatewayv1.SettingsGetRequest{}, - }, - }) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - if errResp := response.GetError(); errResp != nil { - _ = c.writeError(req.ID, errResp.GetMessage()) - return - } - - settingsResp := response.GetSettingsGetResp() - if settingsResp == nil { - _ = c.writeError(req.ID, "unexpected agent response") - return - } - - payload, err := websocketSettingsJSONPayload(settingsResp.GetSettingsJson()) - if err != nil { - _ = c.writeError(req.ID, err.Error()) - return - } - c.sm.ApplySettingsJSON(settingsResp.GetSettingsJson()) - - _ = c.writeResponse(req.ID, payload) -} - -func (c *websocketConnection) handleSettingsUpdate(req websocketRequest) { - payloadJSON, err := websocketRawPayloadJSON(req.Payload) - if err != nil { - _ = c.writeError(req.ID, err.Error()) - return - } - - response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ - RequestId: req.ID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_SettingsUpdate{ - SettingsUpdate: &gatewayv1.SettingsUpdateRequest{ - SettingsJson: payloadJSON, - }, - }, - }) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - if errResp := response.GetError(); errResp != nil { - _ = c.writeError(req.ID, errResp.GetMessage()) - return - } - - settingsResp := response.GetSettingsUpdateResp() - if settingsResp == nil { - _ = c.writeError(req.ID, "unexpected agent response") - return - } - if settingsResp.GetAccepted() { - var patch map[string]any - hasSshPatch := json.Unmarshal([]byte(payloadJSON), &patch) == nil && patch != nil - if hasSshPatch { - _, hasSshPatch = patch["sshPatch"] - } - if !hasSshPatch { - c.sm.ApplySettingsJSONPreservingRemote(payloadJSON) - } - } - - _ = c.writeResponse(req.ID, map[string]any{ - "accepted": settingsResp.GetAccepted(), - "message": strings.TrimSpace(settingsResp.GetMessage()), - }) -} - -func (c *websocketConnection) handleSettingsResetSshKnownHost(req websocketRequest) { - if !c.sm.WebSshTerminalEnabled() { - _ = c.writeError(req.ID, "web SSH terminal is disabled in desktop Remote settings") - return - } - - var body websocketSshKnownHostResetPayload - if err := decodeWebSocketPayload(req.Payload, &body); err != nil { - _ = c.writeError(req.ID, "invalid settings.ssh_known_host.reset payload") - return - } - - host := strings.TrimSpace(body.Host) - if host == "" { - _ = c.writeError(req.ID, "SSH host is required") - return - } - if body.Port == nil || *body.Port <= 0 { - _ = c.writeError(req.ID, "SSH port is required") - return - } - if *body.Port > 65535 { - _ = c.writeError(req.ID, "SSH port must be <= 65535") - return - } - port := uint32(*body.Port) - - response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ - RequestId: req.ID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_SettingsResetSshKnownHost{ - SettingsResetSshKnownHost: &gatewayv1.SettingsResetSshKnownHostRequest{ - Host: host, - Port: port, - }, - }, - }) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - if errResp := response.GetError(); errResp != nil { - _ = c.writeError(req.ID, errResp.GetMessage()) - return - } - - settingsResp := response.GetSettingsResetSshKnownHostResp() - if settingsResp == nil { - _ = c.writeError(req.ID, "unexpected agent response") - return - } - - _ = c.writeResponse(req.ID, map[string]any{ - "deleted": settingsResp.GetDeleted(), - }) -} diff --git a/crates/agent-gateway/internal/server/websocket_sftp_handlers.go b/crates/agent-gateway/internal/server/websocket_sftp_handlers.go deleted file mode 100644 index febcf414a..000000000 --- a/crates/agent-gateway/internal/server/websocket_sftp_handlers.go +++ /dev/null @@ -1,180 +0,0 @@ -// Deprecated: v1 JSON 协议的处理器/载荷塑形,已被 v2 信封直通(internal/protocol/pbws)取代;仅为旧客户端保留,流量归零后整体删除。 -package server - -import ( - "strings" - "time" - - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" -) - -func (c *websocketConnection) handleSftpRequest(req websocketRequest) { - action := sftpActionFromRequestType(req.Type) - - var body websocketSftpRequestPayload - if err := decodeWebSocketPayload(req.Payload, &body); err != nil { - _ = c.writeError(req.ID, "invalid "+req.Type+" payload") - return - } - if !c.sm.WebSshTerminalEnabled() { - _ = c.writeError(req.ID, "web SSH SFTP is disabled in desktop Remote settings") - return - } - - side := strings.TrimSpace(body.Side) - if side == "" { - side = strings.TrimSpace(body.Direction) - } - direction := strings.TrimSpace(body.Direction) - if direction == "" { - direction = side - } - sessionID := firstNonEmptyTrimmed(body.SessionID, body.SessionIDCamel) - projectPathKey := firstNonEmptyTrimmed(body.ProjectPathKey, body.ProjectPathKeyCamel) - localPath := firstNonEmptyRaw(body.LocalPath, body.LocalPathCamel) - remotePath := firstNonEmptyRaw(body.RemotePath, body.RemotePathCamel) - fromPath := firstNonEmptyRaw( - body.FromPath, - body.FromPathCamel, - body.SourcePathCamel, - body.TransferID, - body.TransferIDCamel, - ) - toPath := firstNonEmptyRaw(body.ToPath, body.ToPathCamel) - targetPath := firstNonEmptyRaw(body.TargetPath, body.TargetPathCamel) - - response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ - RequestId: req.ID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_SftpRequest{ - SftpRequest: &gatewayv1.SftpRequest{ - Action: action, - SessionId: sessionID, - ProjectPathKey: projectPathKey, - Workdir: strings.TrimSpace(body.Workdir), - LocalPath: localPath, - RemotePath: remotePath, - FromPath: fromPath, - ToPath: toPath, - Direction: direction, - TargetPath: targetPath, - Recursive: body.Recursive, - Overwrite: body.Overwrite, - }, - }, - }) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - if errResp := response.GetError(); errResp != nil { - _ = c.writeError(req.ID, errResp.GetMessage()) - return - } - - resp := response.GetSftpResponse() - if resp == nil { - _ = c.writeError(req.ID, "unexpected agent response") - return - } - _ = c.writeResponse(req.ID, websocketSftpResponsePayload(resp)) -} - -func sftpActionFromRequestType(requestType string) string { - const prefix = "sftp." - if strings.HasPrefix(requestType, prefix) { - return strings.TrimSpace(strings.TrimPrefix(requestType, prefix)) - } - return strings.TrimSpace(requestType) -} - -func firstNonEmptyTrimmed(values ...string) string { - for _, value := range values { - if trimmed := strings.TrimSpace(value); trimmed != "" { - return trimmed - } - } - return "" -} - -func firstNonEmptyRaw(values ...string) string { - for _, value := range values { - if value != "" { - return value - } - } - return "" -} - -func websocketSftpResponsePayload(resp *gatewayv1.SftpResponse) map[string]any { - payload := map[string]any{ - "action": strings.TrimSpace(resp.GetAction()), - "path": resp.GetPath(), - "exists": resp.GetExists(), - "entries": websocketSftpEntriesPayload(resp.GetEntries()), - } - if entry := resp.GetEntry(); entry != nil { - payload["entry"] = websocketSftpEntryPayload(entry) - } - if transfer := resp.GetTransfer(); transfer != nil { - payload["transfer"] = websocketSftpTransferPayload(transfer) - } - return payload -} - -func websocketSftpEventPayload(event *gatewayv1.SftpEvent) map[string]any { - payload := map[string]any{ - "kind": strings.TrimSpace(event.GetKind()), - } - if transfer := event.GetTransfer(); transfer != nil { - payload["transfer"] = websocketSftpTransferPayload(transfer) - } - return payload -} - -func websocketSftpEntriesPayload(entries []*gatewayv1.SftpEntry) []map[string]any { - payload := make([]map[string]any, 0, len(entries)) - for _, entry := range entries { - if entry == nil { - continue - } - payload = append(payload, websocketSftpEntryPayload(entry)) - } - return payload -} - -func websocketSftpEntryPayload(entry *gatewayv1.SftpEntry) map[string]any { - return map[string]any{ - "path": entry.GetPath(), - "name": entry.GetName(), - "kind": strings.TrimSpace(entry.GetKind()), - "sizeBytes": entry.GetSizeBytes(), - "size_bytes": entry.GetSizeBytes(), - "mtime": entry.GetMtime(), - } -} - -func websocketSftpTransferPayload(transfer *gatewayv1.SftpTransfer) map[string]any { - return map[string]any{ - "id": strings.TrimSpace(transfer.GetId()), - "sessionId": strings.TrimSpace(transfer.GetSessionId()), - "session_id": strings.TrimSpace(transfer.GetSessionId()), - "direction": strings.TrimSpace(transfer.GetDirection()), - "status": strings.TrimSpace(transfer.GetStatus()), - "sourcePath": transfer.GetSourcePath(), - "source_path": transfer.GetSourcePath(), - "targetPath": transfer.GetTargetPath(), - "target_path": transfer.GetTargetPath(), - "currentPath": transfer.GetCurrentPath(), - "current_path": transfer.GetCurrentPath(), - "bytesDone": transfer.GetBytesDone(), - "bytes_done": transfer.GetBytesDone(), - "bytesTotal": transfer.GetBytesTotal(), - "bytes_total": transfer.GetBytesTotal(), - "filesDone": transfer.GetFilesDone(), - "files_done": transfer.GetFilesDone(), - "filesTotal": transfer.GetFilesTotal(), - "files_total": transfer.GetFilesTotal(), - "error": strings.TrimSpace(transfer.GetError()), - } -} diff --git a/crates/agent-gateway/internal/server/websocket_sftp_handlers_test.go b/crates/agent-gateway/internal/server/websocket_sftp_handlers_test.go deleted file mode 100644 index 502955182..000000000 --- a/crates/agent-gateway/internal/server/websocket_sftp_handlers_test.go +++ /dev/null @@ -1,75 +0,0 @@ -package server - -import ( - "testing" - - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" -) - -func TestFirstNonEmptyRawPreservesSftpPathWhitespace(t *testing.T) { - t.Parallel() - - if got := firstNonEmptyRaw("", " spaced path "); got != " spaced path " { - t.Fatalf("firstNonEmptyRaw preserved path = %q", got) - } - if got := firstNonEmptyRaw(" ", "fallback"); got != " " { - t.Fatalf("firstNonEmptyRaw all-space path = %q", got) - } -} - -func TestWebsocketSftpPayloadPreservesPathWhitespace(t *testing.T) { - t.Parallel() - - payload := websocketSftpResponsePayload(&gatewayv1.SftpResponse{ - Action: " list ", - Path: " remote dir ", - Entries: []*gatewayv1.SftpEntry{ - { - Path: " remote dir/file ", - Name: " file ", - Kind: " file ", - }, - }, - Transfer: &gatewayv1.SftpTransfer{ - Id: " transfer-1 ", - SessionId: " session-1 ", - Direction: " upload ", - Status: " running ", - SourcePath: " local file ", - TargetPath: " remote dir ", - CurrentPath: " remote dir/file ", - }, - }) - - if got := payload["action"]; got != "list" { - t.Fatalf("action = %#v", got) - } - if got := payload["path"]; got != " remote dir " { - t.Fatalf("path = %#v", got) - } - - entries := payload["entries"].([]map[string]any) - if got := entries[0]["path"]; got != " remote dir/file " { - t.Fatalf("entry path = %#v", got) - } - if got := entries[0]["name"]; got != " file " { - t.Fatalf("entry name = %#v", got) - } - if got := entries[0]["kind"]; got != "file" { - t.Fatalf("entry kind = %#v", got) - } - - transfer := payload["transfer"].(map[string]any) - if got := transfer["id"]; got != "transfer-1" { - t.Fatalf("transfer id = %#v", got) - } - if got := transfer["sourcePath"]; got != " local file " { - t.Fatalf("sourcePath = %#v", got) - } - if got := transfer["targetPath"]; got != " remote dir " { - t.Fatalf("targetPath = %#v", got) - } - if got := transfer["currentPath"]; got != " remote dir/file " { - t.Fatalf("currentPath = %#v", got) - } -} diff --git a/crates/agent-gateway/internal/server/websocket_skills_handlers.go b/crates/agent-gateway/internal/server/websocket_skills_handlers.go deleted file mode 100644 index 570af8672..000000000 --- a/crates/agent-gateway/internal/server/websocket_skills_handlers.go +++ /dev/null @@ -1,270 +0,0 @@ -// Deprecated: v1 JSON 协议的处理器/载荷塑形,已被 v2 信封直通(internal/protocol/pbws)取代;仅为旧客户端保留,流量归零后整体删除。 -package server - -import ( - "encoding/json" - "strings" - "time" - - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" -) - -func (c *websocketConnection) handleSkillFilesList(req websocketRequest) { - response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ - RequestId: req.ID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_SkillFilesList{ - SkillFilesList: &gatewayv1.SkillFilesListRequest{}, - }, - }) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - if errResp := response.GetError(); errResp != nil { - _ = c.writeError(req.ID, errResp.GetMessage()) - return - } - - resp := response.GetSkillFilesListResp() - if resp == nil { - _ = c.writeError(req.ID, "unexpected agent response") - return - } - - _ = c.writeResponse(req.ID, map[string]any{ - "rootDir": resp.GetRootDir(), - "paths": resp.GetPaths(), - "truncated": resp.GetTruncated(), - }) -} - -func (c *websocketConnection) handleFileMentionList(req websocketRequest) { - type payload struct { - Workdir string `json:"workdir"` - MaxResults *int `json:"max_results"` - Query string `json:"query"` - ShowHidden *bool `json:"show_hidden"` - } - - var body payload - if err := decodeWebSocketPayload(req.Payload, &body); err != nil { - _ = c.writeError(req.ID, "invalid mentions.list payload") - return - } - - workdir := strings.TrimSpace(body.Workdir) - if workdir == "" { - _ = c.writeError(req.ID, "workdir is required") - return - } - query := strings.TrimSpace(body.Query) - - maxResults, err := websocketOptionalUint32(body.MaxResults, "max_results") - if err != nil { - _ = c.writeError(req.ID, err.Error()) - return - } - - response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ - RequestId: req.ID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_FileMentionList{ - FileMentionList: &gatewayv1.FileMentionListRequest{ - Workdir: workdir, - MaxResults: maxResults, - Query: query, - ShowHidden: body.ShowHidden, - }, - }, - }) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - if errResp := response.GetError(); errResp != nil { - _ = c.writeError(req.ID, errResp.GetMessage()) - return - } - - resp := response.GetFileMentionListResp() - if resp == nil { - _ = c.writeError(req.ID, "unexpected agent response") - return - } - - entries := make([]map[string]any, 0, len(resp.GetEntries())) - for _, entry := range resp.GetEntries() { - entries = append(entries, map[string]any{ - "path": entry.GetPath(), - "kind": entry.GetKind(), - "hidden": entry.GetHidden(), - }) - } - - _ = c.writeResponse(req.ID, map[string]any{ - "entries": entries, - "truncated": resp.GetTruncated(), - }) -} - -func (c *websocketConnection) handleSkillMetadataRead(req websocketRequest) { - type payload struct { - Path string `json:"path"` - } - - var body payload - if err := decodeWebSocketPayload(req.Payload, &body); err != nil { - _ = c.writeError(req.ID, "invalid skills.read-metadata payload") - return - } - - path := strings.TrimSpace(body.Path) - if path == "" { - _ = c.writeError(req.ID, "path is required") - return - } - - response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ - RequestId: req.ID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_SkillMetadataRead{ - SkillMetadataRead: &gatewayv1.SkillMetadataReadRequest{ - Path: path, - }, - }, - }) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - if errResp := response.GetError(); errResp != nil { - _ = c.writeError(req.ID, errResp.GetMessage()) - return - } - - resp := response.GetSkillMetadataReadResp() - if resp == nil { - _ = c.writeError(req.ID, "unexpected agent response") - return - } - - name := strings.TrimSpace(resp.GetName()) - description := strings.TrimSpace(resp.GetDescription()) - result := map[string]any{"name": any(nil), "description": any(nil)} - if name != "" { - result["name"] = name - } - if description != "" { - result["description"] = description - } - _ = c.writeResponse(req.ID, result) -} - -func (c *websocketConnection) handleSkillTextRead(req websocketRequest) { - type payload struct { - Path string `json:"path"` - Offset *int `json:"offset"` - Length *int `json:"length"` - } - - var body payload - if err := decodeWebSocketPayload(req.Payload, &body); err != nil { - _ = c.writeError(req.ID, "invalid skills.read-text payload") - return - } - - path := strings.TrimSpace(body.Path) - if path == "" { - _ = c.writeError(req.ID, "path is required") - return - } - - offset, err := websocketOptionalUint32(body.Offset, "offset") - if err != nil { - _ = c.writeError(req.ID, err.Error()) - return - } - length, err := websocketOptionalUint32(body.Length, "length") - if err != nil { - _ = c.writeError(req.ID, err.Error()) - return - } - - response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ - RequestId: req.ID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_SkillTextRead{ - SkillTextRead: &gatewayv1.SkillTextReadRequest{ - Path: path, - Offset: offset, - Length: length, - }, - }, - }) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - if errResp := response.GetError(); errResp != nil { - _ = c.writeError(req.ID, errResp.GetMessage()) - return - } - - resp := response.GetSkillTextReadResp() - if resp == nil { - _ = c.writeError(req.ID, "unexpected agent response") - return - } - - _ = c.writeResponse(req.ID, map[string]any{ - "content": resp.GetContent(), - "truncated": resp.GetTruncated(), - }) -} - -func (c *websocketConnection) handleSkillManage(req websocketRequest) { - payloadJSON := strings.TrimSpace(string(req.Payload)) - if payloadJSON == "" || payloadJSON == "null" { - payloadJSON = "{}" - } - if !json.Valid([]byte(payloadJSON)) { - _ = c.writeError(req.ID, "invalid skills.manage payload") - return - } - - response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ - RequestId: req.ID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_SkillManage{ - SkillManage: &gatewayv1.SkillManageRequest{ - PayloadJson: payloadJSON, - }, - }, - }) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - if errResp := response.GetError(); errResp != nil { - _ = c.writeError(req.ID, errResp.GetMessage()) - return - } - - resp := response.GetSkillManageResp() - if resp == nil { - _ = c.writeError(req.ID, "unexpected agent response") - return - } - - var payload any - raw := strings.TrimSpace(resp.GetResultJson()) - if raw == "" { - payload = map[string]any{} - } else if err := json.Unmarshal([]byte(raw), &payload); err != nil { - _ = c.writeError(req.ID, "skill manage response is not valid JSON") - return - } - - _ = c.writeResponse(req.ID, payload) -} diff --git a/crates/agent-gateway/internal/server/websocket_terminal_handlers.go b/crates/agent-gateway/internal/server/websocket_terminal_handlers.go deleted file mode 100644 index 5cde7a3e4..000000000 --- a/crates/agent-gateway/internal/server/websocket_terminal_handlers.go +++ /dev/null @@ -1,104 +0,0 @@ -// Deprecated: v1 JSON 协议的处理器/载荷塑形,已被 v2 信封直通(internal/protocol/pbws)取代;仅为旧客户端保留,流量归零后整体删除。 -package server - -import ( - "strings" - "time" - - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" - "github.com/liveagent/agent-gateway/internal/protocol/shared" -) - -func terminalActionFromRequestType(requestType string) string { - return strings.TrimPrefix(strings.TrimSpace(requestType), "terminal.") -} - -// Terminal gating and response post-processing live in -// internal/protocol/shared (shared with the v2 protocol); the methods below -// are thin v1 adapters. - -func (c *websocketConnection) terminalFeaturesEnabled() bool { - return shared.TerminalFeaturesEnabled(c.sm) -} - -func (c *websocketConnection) terminalSessionAllowed(session *gatewayv1.TerminalSession) bool { - return shared.TerminalSessionAllowed(c.sm, session) -} - -func (c *websocketConnection) terminalEventAllowed(event *gatewayv1.TerminalEvent) bool { - return shared.TerminalEventAllowed(c.sm, event) -} - -func (c *websocketConnection) handleTerminalRequest(req websocketRequest) { - action := terminalActionFromRequestType(req.Type) - - var body websocketTerminalRequestPayload - if err := decodeWebSocketPayload(req.Payload, &body); err != nil { - _ = c.writeError(req.ID, "invalid "+req.Type+" payload") - return - } - if !shared.TerminalRequestAllowed(c.sm, action, strings.TrimSpace(body.SessionID)) { - _ = c.writeError(req.ID, shared.TerminalPermissionError(action)) - return - } - - cols, err := websocketOptionalUint32(body.Cols, "cols") - if err != nil { - _ = c.writeError(req.ID, err.Error()) - return - } - rows, err := websocketOptionalUint32(body.Rows, "rows") - if err != nil { - _ = c.writeError(req.ID, err.Error()) - return - } - maxBytes, err := websocketOptionalUint32(body.MaxBytes, "max_bytes") - if err != nil { - _ = c.writeError(req.ID, err.Error()) - return - } - projectPathKey := strings.TrimSpace(body.ProjectPathKey) - - response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ - RequestId: req.ID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_TerminalRequest{ - TerminalRequest: &gatewayv1.TerminalRequest{ - Action: action, - SessionId: strings.TrimSpace(body.SessionID), - ProjectPathKey: projectPathKey, - Cwd: strings.TrimSpace(body.Cwd), - Shell: strings.TrimSpace(body.Shell), - Title: strings.TrimSpace(body.Title), - Data: body.Data, - Cols: cols, - Rows: rows, - MaxBytes: maxBytes, - SshHostId: strings.TrimSpace(body.SshHostID), - PromptId: strings.TrimSpace(body.PromptID), - PromptAnswer: body.PromptAnswer, - TrustHostKey: body.TrustHostKey, - SftpEnabled: body.SftpEnabled, - TabId: strings.TrimSpace(body.TabID), - TabKind: strings.TrimSpace(body.TabKind), - }, - }, - }) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - if errResp := response.GetError(); errResp != nil { - _ = c.writeError(req.ID, errResp.GetMessage()) - return - } - - resp := response.GetTerminalResponse() - if resp == nil { - _ = c.writeError(req.ID, "unexpected agent response") - return - } - filteredResp := shared.FinalizeTerminalResponse(c.sm, c.terminalInterest, action, projectPathKey, resp) - - _ = c.writeResponse(req.ID, websocketTerminalResponsePayload(filteredResp)) -} diff --git a/crates/agent-gateway/internal/server/websocket_terminal_stream.go b/crates/agent-gateway/internal/server/websocket_terminal_stream.go deleted file mode 100644 index 62c2eb397..000000000 --- a/crates/agent-gateway/internal/server/websocket_terminal_stream.go +++ /dev/null @@ -1,427 +0,0 @@ -package server - -import ( - "context" - "encoding/binary" - "encoding/json" - "errors" - "log/slog" - "net/http" - "strings" - "sync" - "time" - - "github.com/gorilla/websocket" - "github.com/liveagent/agent-gateway/internal/auth" - "github.com/liveagent/agent-gateway/internal/config" - "github.com/liveagent/agent-gateway/internal/observability" - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" - "github.com/liveagent/agent-gateway/internal/protocol/shared" - "github.com/liveagent/agent-gateway/internal/session" -) - -const ( - terminalStreamFrameVersion = byte(1) - terminalStreamWriteQueue = 1024 -) - -var terminalStreamKindByte = map[string]byte{ - "attach": 1, - "input": 2, - "resize": 3, - "detach": 4, - "output": 5, - "snapshot": 6, - "error": 7, -} - -type terminalStreamAuthPayload struct { - Type string `json:"type"` - Token string `json:"token"` -} - -type terminalStreamFrameHeader struct { - Kind string `json:"kind,omitempty"` - StreamID string `json:"streamId,omitempty"` - SessionID string `json:"sessionId,omitempty"` - ProjectPathKey string `json:"projectPathKey,omitempty"` - Seq uint64 `json:"seq,omitempty"` - StartOffset uint64 `json:"startOffset,omitempty"` - EndOffset uint64 `json:"endOffset,omitempty"` - Cols uint32 `json:"cols,omitempty"` - Rows uint32 `json:"rows,omitempty"` - MaxBytes uint32 `json:"maxBytes,omitempty"` - Truncated bool `json:"truncated,omitempty"` - Error string `json:"error,omitempty"` - Session map[string]any `json:"session,omitempty"` -} - -type terminalStreamWSConnection struct { - cfg *config.Config - sm *session.Manager - - conn *websocket.Conn - out chan []byte - done chan struct{} - once sync.Once - - mu sync.RWMutex - attached map[string]struct{} - streams map[string]struct{} -} - -// NewTerminalWebSocketServer serves the v1 custom binary terminal stream on -// /ws/terminal. -// -// Deprecated: v1 自定义二进制终端流([ver][kind][len][JSON 头][数据])已被 v2 /ws/v2/terminal(proto TerminalStreamFrame 帧)取代,仅为旧客户端保留;流量归零后删除。 -func NewTerminalWebSocketServer(cfg *config.Config, sm *session.Manager) http.Handler { - upgrader := websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { - return originAllowed(r) - }, - } - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - conn, err := upgrader.Upgrade(w, r, nil) - if err != nil { - return - } - conn.SetReadLimit(webSocketReadLimit(cfg)) - - state := &terminalStreamWSConnection{ - cfg: cfg, - sm: sm, - conn: conn, - out: make(chan []byte, terminalStreamWriteQueue), - done: make(chan struct{}), - attached: make(map[string]struct{}), - streams: make(map[string]struct{}), - } - defer state.close() - state.serve() - }) -} - -func (c *terminalStreamWSConnection) serve() { - if !c.authenticate() { - return - } - - go c.writeLoop() - c.startForwarder() - - for { - messageType, payload, err := c.conn.ReadMessage() - if err != nil { - return - } - if messageType != websocket.BinaryMessage { - continue - } - frame, err := decodeTerminalStreamFrame(payload) - if err != nil { - c.enqueueFrame(terminalStreamErrorFrame("", "", "", err.Error())) - continue - } - c.handleFrame(frame) - } -} - -func (c *terminalStreamWSConnection) authenticate() bool { - messageType, payload, err := c.conn.ReadMessage() - if err != nil || messageType != websocket.TextMessage { - return false - } - var authPayload terminalStreamAuthPayload - if err := json.Unmarshal(payload, &authPayload); err != nil { - return false - } - if strings.TrimSpace(authPayload.Type) != "auth" { - return false - } - if !auth.ValidateToken(authPayload.Token, c.cfg.Token) { - _ = c.conn.WriteJSON(map[string]any{"type": "error", "error": "unauthorized"}) - return false - } - observability.Usage.V1TerminalWSConnectionsTotal.Add(1) - slog.Warn("deprecated v1 terminal websocket connection established") - _ = c.conn.WriteJSON(map[string]any{"type": "ready"}) - return true -} - -func (c *terminalStreamWSConnection) handleFrame(frame *gatewayv1.TerminalStreamFrame) { - kind := strings.TrimSpace(frame.GetKind()) - if !c.frameAllowed(frame) { - c.enqueueFrame(terminalStreamErrorFrame( - frame.GetStreamId(), - frame.GetSessionId(), - frame.GetProjectPathKey(), - shared.TerminalPermissionError(kind), - )) - return - } - - switch kind { - case "attach": - c.remember(frame.GetSessionId(), frame.GetStreamId()) - case "detach": - c.forget(frame.GetSessionId(), frame.GetStreamId()) - case "input", "resize": - if !c.isAttached(frame.GetSessionId()) { - c.enqueueFrame(terminalStreamErrorFrame( - frame.GetStreamId(), - frame.GetSessionId(), - frame.GetProjectPathKey(), - "terminal stream is not attached", - )) - return - } - default: - c.enqueueFrame(terminalStreamErrorFrame( - frame.GetStreamId(), - frame.GetSessionId(), - frame.GetProjectPathKey(), - "unsupported terminal stream frame", - )) - return - } - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if err := c.sm.SendTerminalFrameToAgent(ctx, frame); err != nil { - message := "desktop agent is offline" - if !errors.Is(err, session.ErrAgentOffline) { - message = err.Error() - } - c.enqueueFrame(terminalStreamErrorFrame( - frame.GetStreamId(), - frame.GetSessionId(), - frame.GetProjectPathKey(), - message, - )) - } -} - -func (c *terminalStreamWSConnection) frameAllowed(frame *gatewayv1.TerminalStreamFrame) bool { - if frame == nil { - return false - } - sessionID := strings.TrimSpace(frame.GetSessionId()) - switch c.sm.TerminalSessionKind(sessionID) { - case "ssh": - return c.sm.WebSshTerminalEnabled() - case "local": - return c.sm.WebTerminalEnabled() - default: - return c.sm.WebTerminalEnabled() || c.sm.WebSshTerminalEnabled() - } -} - -func (c *terminalStreamWSConnection) startForwarder() { - frames, cleanup := c.sm.SubscribeTerminalStreamFrames() - go func() { - defer cleanup() - for { - select { - case <-c.done: - return - case frame, ok := <-frames: - if !ok { - c.close() - return - } - if !c.shouldForward(frame) { - continue - } - c.enqueueFrame(frame) - } - } - }() -} - -func (c *terminalStreamWSConnection) shouldForward(frame *gatewayv1.TerminalStreamFrame) bool { - if frame == nil { - return false - } - kind := strings.TrimSpace(frame.GetKind()) - if kind == "snapshot" || kind == "error" { - return c.knowsStream(frame.GetStreamId()) - } - if kind != "output" { - return false - } - return c.isAttached(frame.GetSessionId()) -} - -func (c *terminalStreamWSConnection) remember(sessionID string, streamID string) { - sessionID = strings.TrimSpace(sessionID) - streamID = strings.TrimSpace(streamID) - if sessionID == "" && streamID == "" { - return - } - c.mu.Lock() - if sessionID != "" { - c.attached[sessionID] = struct{}{} - } - if streamID != "" { - c.streams[streamID] = struct{}{} - } - c.mu.Unlock() -} - -func (c *terminalStreamWSConnection) forget(sessionID string, streamID string) { - sessionID = strings.TrimSpace(sessionID) - streamID = strings.TrimSpace(streamID) - if sessionID == "" && streamID == "" { - return - } - c.mu.Lock() - if sessionID != "" { - delete(c.attached, sessionID) - } - if streamID != "" { - delete(c.streams, streamID) - } - c.mu.Unlock() -} - -func (c *terminalStreamWSConnection) isAttached(sessionID string) bool { - sessionID = strings.TrimSpace(sessionID) - if sessionID == "" { - return false - } - c.mu.RLock() - _, ok := c.attached[sessionID] - c.mu.RUnlock() - return ok -} - -func (c *terminalStreamWSConnection) knowsStream(streamID string) bool { - streamID = strings.TrimSpace(streamID) - if streamID == "" { - return false - } - c.mu.RLock() - _, ok := c.streams[streamID] - c.mu.RUnlock() - return ok -} - -func (c *terminalStreamWSConnection) enqueueFrame(frame *gatewayv1.TerminalStreamFrame) { - payload, err := encodeTerminalStreamFrame(frame) - if err != nil { - return - } - select { - case <-c.done: - case c.out <- payload: - default: - c.close() - } -} - -func (c *terminalStreamWSConnection) writeLoop() { - for { - select { - case <-c.done: - return - case payload := <-c.out: - if c.cfg != nil && c.cfg.WebSocketWriteTimeout > 0 { - _ = c.conn.SetWriteDeadline(time.Now().Add(c.cfg.WebSocketWriteTimeout)) - } - if err := c.conn.WriteMessage(websocket.BinaryMessage, payload); err != nil { - c.close() - return - } - _ = c.conn.SetWriteDeadline(time.Time{}) - } - } -} - -func (c *terminalStreamWSConnection) close() { - c.once.Do(func() { - close(c.done) - _ = c.conn.Close() - }) -} - -func decodeTerminalStreamFrame(payload []byte) (*gatewayv1.TerminalStreamFrame, error) { - if len(payload) < 4 { - return nil, errors.New("terminal frame is too short") - } - if payload[0] != terminalStreamFrameVersion { - return nil, errors.New("unsupported terminal frame version") - } - headerLen := int(binary.BigEndian.Uint16(payload[2:4])) - if len(payload) < 4+headerLen { - return nil, errors.New("terminal frame header is truncated") - } - var header terminalStreamFrameHeader - if err := json.Unmarshal(payload[4:4+headerLen], &header); err != nil { - return nil, errors.New("terminal frame header is invalid") - } - data := append([]byte(nil), payload[4+headerLen:]...) - return &gatewayv1.TerminalStreamFrame{ - Kind: strings.TrimSpace(header.Kind), - StreamId: strings.TrimSpace(header.StreamID), - SessionId: strings.TrimSpace(header.SessionID), - ProjectPathKey: strings.TrimSpace(header.ProjectPathKey), - Seq: header.Seq, - StartOffset: header.StartOffset, - EndOffset: header.EndOffset, - Cols: header.Cols, - Rows: header.Rows, - MaxBytes: header.MaxBytes, - Truncated: header.Truncated, - Error: strings.TrimSpace(header.Error), - Data: data, - }, nil -} - -func encodeTerminalStreamFrame(frame *gatewayv1.TerminalStreamFrame) ([]byte, error) { - if frame == nil { - return nil, errors.New("terminal frame is nil") - } - kind := strings.TrimSpace(frame.GetKind()) - header := terminalStreamFrameHeader{ - Kind: kind, - StreamID: strings.TrimSpace(frame.GetStreamId()), - SessionID: strings.TrimSpace(frame.GetSessionId()), - ProjectPathKey: strings.TrimSpace(frame.GetProjectPathKey()), - Seq: frame.GetSeq(), - StartOffset: frame.GetStartOffset(), - EndOffset: frame.GetEndOffset(), - Cols: frame.GetCols(), - Rows: frame.GetRows(), - MaxBytes: frame.GetMaxBytes(), - Truncated: frame.GetTruncated(), - Error: strings.TrimSpace(frame.GetError()), - } - if session := websocketTerminalSessionPayload(frame.GetSession()); session != nil { - header.Session = session - } - headerBytes, err := json.Marshal(header) - if err != nil { - return nil, err - } - if len(headerBytes) > 0xffff { - return nil, errors.New("terminal frame header is too large") - } - data := frame.GetData() - payload := make([]byte, 4+len(headerBytes)+len(data)) - payload[0] = terminalStreamFrameVersion - payload[1] = terminalStreamKindByte[kind] - binary.BigEndian.PutUint16(payload[2:4], uint16(len(headerBytes))) - copy(payload[4:], headerBytes) - copy(payload[4+len(headerBytes):], data) - return payload, nil -} - -func terminalStreamErrorFrame(streamID, sessionID, projectPathKey, message string) *gatewayv1.TerminalStreamFrame { - return &gatewayv1.TerminalStreamFrame{ - Kind: "error", - StreamId: strings.TrimSpace(streamID), - SessionId: strings.TrimSpace(sessionID), - ProjectPathKey: strings.TrimSpace(projectPathKey), - Error: strings.TrimSpace(message), - } -} diff --git a/crates/agent-gateway/internal/server/websocket_tunnel_handlers.go b/crates/agent-gateway/internal/server/websocket_tunnel_handlers.go deleted file mode 100644 index 969dfb1e3..000000000 --- a/crates/agent-gateway/internal/server/websocket_tunnel_handlers.go +++ /dev/null @@ -1,166 +0,0 @@ -// Deprecated: v1 JSON 协议的处理器/载荷塑形,已被 v2 信封直通(internal/protocol/pbws)取代;仅为旧客户端保留,流量归零后整体删除。 -package server - -import ( - "errors" - "time" - - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" -) - -type websocketTunnelMutationPayload struct { - TunnelID string `json:"tunnel_id"` - TargetURL string `json:"target_url"` - Name string `json:"name"` - TTLSeconds *uint32 `json:"ttl_seconds"` - ProjectPathKey string `json:"project_path_key"` -} - -func (c *websocketConnection) handleTunnelCreate(req websocketRequest) { - c.handleTunnelMutation(req, "create") -} - -func (c *websocketConnection) handleTunnelUpdate(req websocketRequest) { - c.handleTunnelMutation(req, "update") -} - -func (c *websocketConnection) handleTunnelClose(req websocketRequest) { - c.handleTunnelMutation(req, "close") -} - -func (c *websocketConnection) handleTunnelCheck(req websocketRequest) { - c.handleTunnelMutation(req, "check") -} - -// handleTunnelMutation forwards a webui tunnel mutation to the agent (the -// desired-state owner) and relays its verdict. State itself arrives on every -// client through the tunnel.state broadcast that the mutation triggers. -func (c *websocketConnection) handleTunnelMutation(req websocketRequest, action string) { - if !c.sm.WebTunnelsEnabled() { - _ = c.writeError(req.ID, "web tunnels are disabled in desktop Remote settings") - return - } - - var body websocketTunnelMutationPayload - if err := decodeWebSocketPayload(req.Payload, &body); err != nil { - _ = c.writeError(req.ID, "invalid tunnel."+action+" payload") - return - } - - mutation := &gatewayv1.TunnelMutation{ - Action: action, - TunnelId: body.TunnelID, - TargetUrl: body.TargetURL, - Name: body.Name, - TtlSeconds: body.TTLSeconds, - ProjectPathKey: body.ProjectPathKey, - } - - response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ - RequestId: req.ID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_TunnelMutation{ - TunnelMutation: mutation, - }, - }) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - if errResp := response.GetError(); errResp != nil { - _ = c.writeError(req.ID, errResp.GetMessage()) - return - } - result := response.GetTunnelMutationResult() - if result == nil { - _ = c.writeError(req.ID, "unexpected agent response") - return - } - if result.GetErrorMessage() != "" { - _ = c.writeError(req.ID, result.GetErrorMessage()) - return - } - _ = c.writeResponse(req.ID, map[string]any{ - "tunnel_id": result.GetTunnelId(), - }) -} - -func (c *websocketConnection) startTunnelStateForwarder() { - if c.tunnelStateEvents != nil || c.tunnelStateEventsCleanup != nil { - return - } - - tunnelStateEvents, cleanup := c.sm.SubscribeTunnelState() - c.tunnelStateEvents = tunnelStateEvents - c.tunnelStateEventsCleanup = cleanup - - go func() { - for { - select { - case <-c.done: - return - case snapshot, ok := <-tunnelStateEvents: - if !ok { - return - } - if err := c.writeEvent("tunnel.state", websocketTunnelStatePayload(snapshot)); err != nil { - if errors.Is(err, errWriteQueueFull) { - continue - } - return - } - } - } - }() -} - -func (c *websocketConnection) replayTunnelStateSnapshot() { - _ = c.writeEvent("tunnel.state", websocketTunnelStatePayload(c.sm.TunnelStateSnapshot())) -} - -func websocketTunnelStatePayload(snapshot *gatewayv1.TunnelStateSnapshot) map[string]any { - if snapshot == nil { - return map[string]any{ - "revision": 0, - "agent_online": false, - "tunnels": []map[string]any{}, - } - } - tunnels := make([]map[string]any, 0, len(snapshot.GetTunnels())) - for _, tunnel := range snapshot.GetTunnels() { - if tunnel == nil { - continue - } - tunnels = append(tunnels, map[string]any{ - "id": tunnel.GetId(), - "slug": tunnel.GetSlug(), - "name": tunnel.GetName(), - "target_url": tunnel.GetTargetUrl(), - "public_path": tunnel.GetPublicPath(), - "created_at": tunnel.GetCreatedAt(), - "expires_at": tunnel.GetExpiresAt(), - "active_connections": tunnel.GetActiveConnections(), - "project_path_key": tunnel.GetProjectPathKey(), - "local": websocketTunnelHealthPayload(tunnel.GetLocal()), - }) - } - return map[string]any{ - "revision": snapshot.GetRevision(), - "agent_online": snapshot.GetAgentOnline(), - "relay": websocketTunnelHealthPayload(snapshot.GetRelay()), - "tunnels": tunnels, - } -} - -func websocketTunnelHealthPayload(health *gatewayv1.TunnelHealth) map[string]any { - if health == nil { - return nil - } - return map[string]any{ - "status": health.GetStatus(), - "http_status": health.GetHttpStatus(), - "error": health.GetError(), - "checked_at": health.GetCheckedAt(), - "rtt_ms": health.GetRttMs(), - } -} diff --git a/crates/agent-gateway/internal/server/websocket_upload_handlers.go b/crates/agent-gateway/internal/server/websocket_upload_handlers.go deleted file mode 100644 index 422e28a5b..000000000 --- a/crates/agent-gateway/internal/server/websocket_upload_handlers.go +++ /dev/null @@ -1,58 +0,0 @@ -// Deprecated: v1 JSON 协议的处理器/载荷塑形,已被 v2 信封直通(internal/protocol/pbws)取代;仅为旧客户端保留,流量归零后整体删除。 -package server - -import ( - "strings" - "time" - - "github.com/liveagent/agent-gateway/internal/handler" - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" -) - -func (c *websocketConnection) handleUploadedImagePreview(req websocketRequest) { - var body handler.UploadedImagePreviewRequestBody - if err := decodeWebSocketPayload(req.Payload, &body); err != nil { - _ = c.writeError(req.ID, "invalid files.preview payload") - return - } - body.Workdir = strings.TrimSpace(body.Workdir) - body.AbsolutePath = strings.TrimSpace(body.AbsolutePath) - if body.Workdir == "" { - _ = c.writeError(req.ID, "workdir is required") - return - } - if body.AbsolutePath == "" { - _ = c.writeError(req.ID, "absolute_path is required") - return - } - - response, err := c.awaitAgentResponse(req.ID, &gatewayv1.GatewayEnvelope{ - RequestId: req.ID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.GatewayEnvelope_UploadedImagePreview{ - UploadedImagePreview: &gatewayv1.UploadedImagePreviewRequest{ - Workdir: body.Workdir, - AbsolutePath: body.AbsolutePath, - }, - }, - }) - if err != nil { - _ = c.writeError(req.ID, websocketErrorMessage(err)) - return - } - if errResp := response.GetError(); errResp != nil { - _ = c.writeError(req.ID, errResp.GetMessage()) - return - } - - resp := response.GetUploadedImagePreviewResp() - if resp == nil { - _ = c.writeError(req.ID, "unexpected agent response") - return - } - - _ = c.writeResponse(req.ID, map[string]any{ - "mimeType": resp.GetMimeType(), - "data": resp.GetData(), - }) -} diff --git a/crates/agent-gateway/internal/server/websocket_workspace_handlers.go b/crates/agent-gateway/internal/server/websocket_workspace_handlers.go deleted file mode 100644 index ebda976ff..000000000 --- a/crates/agent-gateway/internal/server/websocket_workspace_handlers.go +++ /dev/null @@ -1,132 +0,0 @@ -// Deprecated: v1 JSON 协议的处理器/载荷塑形,已被 v2 信封直通(internal/protocol/pbws)取代;仅为旧客户端保留,流量归零后整体删除。 -package server - -import ( - "errors" - "strings" - "sync" - - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" -) - -// workspaceActivitySubscription is one workdir subscription on a websocket -// connection. It ends on workspace.unsubscribe, a replacing -// workspace.subscribe for the same workdir, or connection close. -type workspaceActivitySubscription struct { - cancel func() - done chan struct{} - once sync.Once -} - -func (s *workspaceActivitySubscription) close() { - s.once.Do(func() { - close(s.done) - s.cancel() - }) -} - -func (c *websocketConnection) handleWorkspaceSubscribe(req websocketRequest) { - var payload struct { - Workdir string `json:"workdir"` - } - if err := decodeWebSocketPayload(req.Payload, &payload); err != nil { - _ = c.writeError(req.ID, "invalid workspace.subscribe payload") - return - } - workdir := strings.TrimSpace(payload.Workdir) - if workdir == "" { - _ = c.writeError(req.ID, "workdir is required") - return - } - - events, cancel := c.sm.SubscribeWorkspaceActivity(workdir) - sub := &workspaceActivitySubscription{ - cancel: cancel, - done: make(chan struct{}), - } - - c.workspaceSubsMu.Lock() - if c.workspaceSubs == nil { - c.workspaceSubs = make(map[string]*workspaceActivitySubscription) - } - if previous := c.workspaceSubs[workdir]; previous != nil { - previous.close() - } - c.workspaceSubs[workdir] = sub - c.workspaceSubsMu.Unlock() - - if err := c.writeResponse(req.ID, map[string]any{"ok": true}); err != nil { - sub.close() - return - } - - go c.forwardWorkspaceActivity(sub, events) -} - -func (c *websocketConnection) forwardWorkspaceActivity( - sub *workspaceActivitySubscription, - events <-chan *gatewayv1.WorkspaceActivityEvent, -) { - for { - select { - case <-c.done: - return - case <-sub.done: - return - case event, ok := <-events: - if !ok { - return - } - if err := c.writeEvent("workspace.activity", websocketWorkspaceActivityPayload(event)); err != nil { - if errors.Is(err, errWriteQueueFull) { - continue - } - return - } - } - } -} - -func (c *websocketConnection) handleWorkspaceUnsubscribe(req websocketRequest) { - var payload struct { - Workdir string `json:"workdir"` - } - if err := decodeWebSocketPayload(req.Payload, &payload); err != nil { - _ = c.writeError(req.ID, "invalid workspace.unsubscribe payload") - return - } - workdir := strings.TrimSpace(payload.Workdir) - - c.workspaceSubsMu.Lock() - if sub := c.workspaceSubs[workdir]; sub != nil { - sub.close() - delete(c.workspaceSubs, workdir) - } - c.workspaceSubsMu.Unlock() - - _ = c.writeResponse(req.ID, map[string]any{"ok": true}) -} - -func (c *websocketConnection) cleanupWorkspaceSubscriptions() { - c.workspaceSubsMu.Lock() - for workdir, sub := range c.workspaceSubs { - sub.close() - delete(c.workspaceSubs, workdir) - } - c.workspaceSubsMu.Unlock() -} - -func websocketWorkspaceActivityPayload(event *gatewayv1.WorkspaceActivityEvent) map[string]any { - changedPaths := event.GetChangedPaths() - if changedPaths == nil { - changedPaths = []string{} - } - return map[string]any{ - "workdir": event.GetWorkdir(), - "revision": event.GetRevision(), - "fs": event.GetFs(), - "git": event.GetGit(), - "changedPaths": changedPaths, - "truncated": event.GetTruncated(), - } -} diff --git a/crates/agent-gateway/internal/server/websocket_write_test.go b/crates/agent-gateway/internal/server/websocket_write_test.go deleted file mode 100644 index f2443230e..000000000 --- a/crates/agent-gateway/internal/server/websocket_write_test.go +++ /dev/null @@ -1,137 +0,0 @@ -package server - -import ( - "encoding/json" - "errors" - "testing" - "time" - - "github.com/liveagent/agent-gateway/internal/transport/wscore" -) - -// 写泵队列语义(等待/掉帧/关闭/心跳静默丢弃)测试已随运行时抽取移至 internal/transport/wscore/conn_test.go;本文件只测 v1 层职责:信封到帧类别的分类与包装后的关键端到端行为。 - -func newEnqueueTestConnection(outboxSize int, writeTimeout time.Duration) *websocketConnection { - core := wscore.NewConn(nil, wscore.Config{ - QueueSize: outboxSize, - WriteTimeout: writeTimeout, - }) - return &websocketConnection{core: core, done: core.Done()} -} - -func TestClassifyEnvelope(t *testing.T) { - t.Parallel() - - cases := []struct { - name string - envelope websocketEnvelope - want wscore.FrameClass - }{ - {"ping", websocketEnvelope{Type: "ping"}, wscore.FramePing}, - {"error", websocketEnvelope{Type: "error"}, wscore.FrameControl}, - {"subscription reset", websocketEnvelope{Type: "chat.subscription_reset"}, wscore.FrameControl}, - {"command update", websocketEnvelope{Type: "chat.command_update"}, wscore.FrameControl}, - {"priority response", websocketEnvelope{ID: "r1", Type: "response", priority: true}, wscore.FrameControl}, - {"correlated response", websocketEnvelope{ID: "r1", Type: "response"}, wscore.FrameResponse}, - {"uncorrelated response", websocketEnvelope{Type: "response"}, wscore.FrameData}, - {"event", websocketEnvelope{Type: "chat.event"}, wscore.FrameData}, - } - for _, tc := range cases { - if got := classifyEnvelope(tc.envelope); got != tc.want { - t.Fatalf("classifyEnvelope(%s) = %d, want %d", tc.name, got, tc.want) - } - } -} - -func TestWriteEnvelopeRoutesControlTypesToControlQueue(t *testing.T) { - t.Parallel() - - c := newEnqueueTestConnection(1, 50*time.Millisecond) - - for _, envelopeType := range []string{"ping", "error", "chat.subscription_reset", "chat.command_update"} { - if err := c.writeEnvelope(websocketEnvelope{Type: envelopeType}); err != nil { - t.Fatalf("writeEnvelope(%q) = %v, want nil", envelopeType, err) - } - } - if got := len(c.core.CtrlOutbox); got != 4 { - t.Fatalf("control queue depth = %d, want 4", got) - } - if got := len(c.core.Outbox); got != 0 { - t.Fatalf("data queue depth = %d, want 0", got) - } - - if err := c.writeEnvelope(websocketEnvelope{Type: "chat.event"}); err != nil { - t.Fatalf("writeEnvelope(chat.event) = %v, want nil", err) - } - if got := len(c.core.Outbox); got != 1 { - t.Fatalf("data queue depth after chat.event = %d, want 1", got) - } -} - -func TestWritePriorityResponseUsesControlQueue(t *testing.T) { - t.Parallel() - - c := newEnqueueTestConnection(1, 50*time.Millisecond) - c.core.Outbox <- wscore.Frame{Kind: "chat.event"} - - if err := c.writePriorityResponse("cmd-1", map[string]any{"run_id": "run-1"}); err != nil { - t.Fatalf("writePriorityResponse: %v", err) - } - select { - case frame := <-c.core.CtrlOutbox: - if frame.Class != wscore.FrameControl || frame.RequestID != "cmd-1" || frame.Kind != "response" { - t.Fatalf("priority response frame = %#v", frame) - } - var envelope struct { - ID string `json:"id"` - Type string `json:"type"` - } - if err := json.Unmarshal(frame.Data, &envelope); err != nil { - t.Fatalf("decode priority response frame: %v", err) - } - if envelope.ID != "cmd-1" || envelope.Type != "response" { - t.Fatalf("priority response envelope = %#v", envelope) - } - default: - t.Fatal("priority response was not routed to the control queue") - } -} - -func TestWriteEnvelopeQueueFullDoesNotCloseConnection(t *testing.T) { - t.Parallel() - - c := newEnqueueTestConnection(1, 20*time.Millisecond) - c.core.Outbox <- wscore.Frame{Kind: "chat.event"} - - err := c.writeEnvelope(websocketEnvelope{Type: "chat.event"}) - if !errors.Is(err, errWriteQueueFull) { - t.Fatalf("writeEnvelope with stuck outbox = %v, want errWriteQueueFull", err) - } - select { - case <-c.done: - t.Fatal("writeEnvelope closed the connection on a full data queue") - default: - } - if got := c.core.DroppedFrames(); got != 1 { - t.Fatalf("DroppedFrames = %d, want 1", got) - } -} - -func TestWriteResponseQueueFullClosesConnectionForRecovery(t *testing.T) { - t.Parallel() - - c := newEnqueueTestConnection(1, 20*time.Millisecond) - c.core.Outbox <- wscore.Frame{Kind: "chat.event"} - - err := c.writeResponse("history-1", map[string]any{"total_count": 1}) - if !errors.Is(err, errWriteQueueFull) { - t.Fatalf("writeResponse with stuck outbox = %v, want errWriteQueueFull", err) - } - select { - case <-c.done: - // Expected: the client observes a disconnect and can recover the - // correlated request instead of waiting for a silently dropped reply. - default: - t.Fatal("writeResponse left the connection open after dropping a correlated response") - } -} diff --git a/crates/agent-gateway/internal/session/conversation_ingress.go b/crates/agent-gateway/internal/session/conversation_ingress.go index 2d2b6c91a..de3c7d7f5 100644 --- a/crates/agent-gateway/internal/session/conversation_ingress.go +++ b/crates/agent-gateway/internal/session/conversation_ingress.go @@ -8,7 +8,7 @@ import ( gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" ) -// Ingress normalization: the three agent-facing gRPC payloads (ChatEvent, +// Ingress normalization: the three agent-facing envelope payloads (ChatEvent, // ChatControlEvent, ChatRuntimeSnapshot) converge here into one append API on // the conversation stream store. Payload shaping and tool-result trimming // happen exactly once, so every subscriber observes identical events. diff --git a/crates/agent-gateway/internal/session/manager_dispatch.go b/crates/agent-gateway/internal/session/manager_dispatch.go index e792efde1..b33d18643 100644 --- a/crates/agent-gateway/internal/session/manager_dispatch.go +++ b/crates/agent-gateway/internal/session/manager_dispatch.go @@ -94,7 +94,7 @@ func (m *Manager) dispatchFromAgent(expected *AgentSession, env *gatewayv1.Agent } // Desired-state and probe payloads fan out broadcasts and relay probes; - // run them off the gRPC read loop so tunnel frames keep flowing. + // run them off the agent stream read loop so tunnel frames keep flowing. if tunnelDesired := env.GetTunnelDesired(); tunnelDesired != nil { go m.ApplyDesiredState(tunnelDesired) return diff --git a/crates/agent-gateway/internal/session/tunnel_state.go b/crates/agent-gateway/internal/session/tunnel_state.go index 8bd0c6b16..b7a41f1b0 100644 --- a/crates/agent-gateway/internal/session/tunnel_state.go +++ b/crates/agent-gateway/internal/session/tunnel_state.go @@ -464,7 +464,7 @@ func (m *Manager) SendTunnelFrameToAgent(frame *gatewayv1.TunnelFrame) error { } // dispatchTunnelFrame routes an agent frame to its visitor stream. It runs on -// the agent gRPC read loop, so it must never block: a full stream channel +// the agent stream read loop, so it must never block: a full stream channel // closes the stream (the visitor handler cancels) instead of waiting. func (m *Manager) dispatchTunnelFrame(frame *gatewayv1.TunnelFrame) { if frame == nil { diff --git a/crates/agent-gateway/internal/session/workspace_activity.go b/crates/agent-gateway/internal/session/workspace_activity.go index 8064757ac..6bb64c3f4 100644 --- a/crates/agent-gateway/internal/session/workspace_activity.go +++ b/crates/agent-gateway/internal/session/workspace_activity.go @@ -83,7 +83,7 @@ func (m *Manager) SubscribeWorkspaceActivity( } // broadcastWorkspaceActivity fans one agent event out to the subscribers of -// its workdir. Runs on the agent gRPC read loop, so it must never block: a +// its workdir. Runs on the agent stream read loop, so it must never block: a // full subscriber channel drops the event (consumers converge on the next // one, and revision gaps are already tolerated client-side). func (m *Manager) broadcastWorkspaceActivity(event *gatewayv1.WorkspaceActivityEvent) { diff --git a/crates/agent-gateway/proto/v1/gateway.proto b/crates/agent-gateway/proto/v1/gateway.proto index 78baa1aeb..b9f626290 100644 --- a/crates/agent-gateway/proto/v1/gateway.proto +++ b/crates/agent-gateway/proto/v1/gateway.proto @@ -4,16 +4,9 @@ package liveagent.gateway.v1; option go_package = "github.com/liveagent/agent-gateway/internal/proto/v1;gatewayv1"; -// AgentGateway 是 v1 的桌面端 gRPC 服务。已弃用:v2 起由 /ws/v2/agent 与 /ws/v2/terminal -// (见 proto/v2/gateway_ws.proto)承担,仅为旧版桌面客户端保留,流量归零后删除; -// 本文件的消息是 v2 的业务载荷,全部保留、永不弃用。 -service AgentGateway { - option deprecated = true; - - rpc AgentConnect(stream AgentEnvelope) returns (stream GatewayEnvelope); - rpc AgentTerminalConnect(stream TerminalStreamFrame) returns (stream TerminalStreamFrame); - rpc Authenticate(AuthRequest) returns (AuthResponse); -} +// 本文件是三端共享的业务消息单一事实源(v2 帧壳见 proto/v2/gateway_ws.proto, +// 其载荷全部复用这里的消息)。v1 gRPC 服务 AgentGateway 已随 v1 协议移除; +// 消息全部保留、永不弃用,编号只增不改。 message AuthRequest { string token = 1; diff --git a/crates/agent-gateway/test/auth/auth_test.go b/crates/agent-gateway/test/auth/auth_test.go index 097881d0e..3cd55e26b 100644 --- a/crates/agent-gateway/test/auth/auth_test.go +++ b/crates/agent-gateway/test/auth/auth_test.go @@ -1,16 +1,11 @@ package auth_test import ( - "context" "net/http" "net/http/httptest" "testing" "github.com/liveagent/agent-gateway/internal/auth" - "google.golang.org/grpc" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/metadata" - "google.golang.org/grpc/status" ) func TestHTTPMiddlewareRequiresValidBearerToken(t *testing.T) { @@ -88,49 +83,3 @@ func TestValidateTokenTrimsAndRejectsEmptyValues(t *testing.T) { t.Fatal("ValidateToken should reject mismatched tokens") } } - -func TestGRPCUnaryInterceptorAuthBoundary(t *testing.T) { - t.Parallel() - - interceptor := auth.GRPCUnaryInterceptor(" secret-token\r\n") - handler := func(_ context.Context, req any) (any, error) { - return req, nil - } - - got, err := interceptor( - context.Background(), - "auth request", - &grpc.UnaryServerInfo{FullMethod: "/liveagent.gateway.v1.AgentGateway/Authenticate"}, - handler, - ) - if err != nil { - t.Fatalf("Authenticate should bypass metadata auth: %v", err) - } - if got != "auth request" { - t.Fatalf("handler result = %#v", got) - } - - _, err = interceptor( - context.Background(), - "protected request", - &grpc.UnaryServerInfo{FullMethod: "/liveagent.gateway.v1.AgentGateway/Other"}, - handler, - ) - if status.Code(err) != codes.Unauthenticated { - t.Fatalf("missing metadata code = %v, want %v", status.Code(err), codes.Unauthenticated) - } - - ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("authorization", "Bearer secret-token")) - got, err = interceptor( - ctx, - "protected request", - &grpc.UnaryServerInfo{FullMethod: "/liveagent.gateway.v1.AgentGateway/Other"}, - handler, - ) - if err != nil { - t.Fatalf("valid bearer metadata rejected: %v", err) - } - if got != "protected request" { - t.Fatalf("handler result = %#v", got) - } -} diff --git a/crates/agent-gateway/test/websocket/chat_test.go b/crates/agent-gateway/test/websocket/chat_test.go deleted file mode 100644 index 2b5627f29..000000000 --- a/crates/agent-gateway/test/websocket/chat_test.go +++ /dev/null @@ -1,424 +0,0 @@ -package websocket_test - -import ( - "encoding/json" - "testing" - "time" - - "github.com/gorilla/websocket" - "github.com/liveagent/agent-gateway/internal/config" - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" - "github.com/liveagent/agent-gateway/internal/server" - "github.com/liveagent/agent-gateway/internal/session" -) - -func newChatWebSocketTest(t *testing.T) (*session.Manager, *session.AgentSession, *websocket.Conn, func()) { - t.Helper() - - sm := session.NewManager() - sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") - agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) - sm.SetSession(agentSession) - - handler := server.NewWebSocketServer(&config.Config{ - Token: "ws-token", - RequestTimeout: time.Second, - }, sm) - conn, cleanup := dialGatewayWebSocket(t, handler) - authWebSocket(t, conn, "ws-token") - return sm, agentSession, conn, cleanup -} - -func decodePayload(t *testing.T, env wsEnvelope) map[string]any { - t.Helper() - payload := map[string]any{} - if len(env.Payload) > 0 { - if err := json.Unmarshal(env.Payload, &payload); err != nil { - t.Fatalf("decode payload for %s: %v", env.Type, err) - } - } - return payload -} - -// receiveEventOfType skips unrelated frames (pings, other event types) until -// an envelope of the wanted type arrives. -func receiveEventOfType(t *testing.T, conn *websocket.Conn, eventType string) map[string]any { - t.Helper() - for attempt := 0; attempt < 16; attempt++ { - env := receiveEnvelope(t, conn) - if env.Type == eventType { - return decodePayload(t, env) - } - } - t.Fatalf("timed out waiting for %s event", eventType) - return nil -} - -func dispatchStarted(sm *session.Manager, runID string, conversationID string) { - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: runID, - Payload: &gatewayv1.AgentEnvelope_ChatControl{ - ChatControl: &gatewayv1.ChatControlEvent{ - RequestId: runID, - ConversationId: conversationID, - Type: "started", - State: "running", - }, - }, - }) -} - -func dispatchToken(sm *session.Manager, runID string, conversationID string, text string) { - data, _ := json.Marshal(map[string]any{"text": text}) - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: runID, - Payload: &gatewayv1.AgentEnvelope_ChatEvent{ - ChatEvent: &gatewayv1.ChatEvent{ - Type: gatewayv1.ChatEvent_TOKEN, - ConversationId: conversationID, - Data: string(data), - }, - }, - }) -} - -func dispatchDone(sm *session.Manager, runID string, conversationID string) { - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: runID, - Payload: &gatewayv1.AgentEnvelope_ChatEvent{ - ChatEvent: &gatewayv1.ChatEvent{ - Type: gatewayv1.ChatEvent_DONE, - ConversationId: conversationID, - Data: "{}", - }, - }, - }) -} - -// The subscription is conversation-scoped and persists across run boundaries: -// a queued prompt auto-send (new run started by the desktop app) streams into -// the same subscription with no re-subscribe handshake. -func TestChatSubscribePersistsAcrossRunHandoff(t *testing.T) { - t.Parallel() - - sm, _, conn, cleanup := newChatWebSocketTest(t) - defer cleanup() - - sendEnvelope(t, conn, "sub-1", "chat.subscribe", map[string]any{ - "conversation_id": "conversation-1", - }) - resp := decodePayload(t, receiveEnvelopeWithID(t, conn, "sub-1")) - if resp["conversation_id"] != "conversation-1" || resp["stream_epoch"] == "" { - t.Fatalf("subscribe response = %#v", resp) - } - if resp["activity"] != nil { - t.Fatalf("idle conversation must report nil activity, got %#v", resp["activity"]) - } - - dispatchStarted(sm, "run-1", "conversation-1") - started := receiveEventOfType(t, conn, "chat.event") - if started["type"] != "run_started" || started["run_id"] != "run-1" { - t.Fatalf("first push = %#v, want run_started run-1", started) - } - - dispatchToken(sm, "run-1", "conversation-1", "hello") - token := receiveEventOfType(t, conn, "chat.event") - if token["type"] != "token" || token["run_id"] != "run-1" || token["text"] != "hello" { - t.Fatalf("token push = %#v", token) - } - - dispatchDone(sm, "run-1", "conversation-1") - finished := receiveEventOfType(t, conn, "chat.event") - if finished["type"] != "run_finished" || finished["status"] != "completed" { - t.Fatalf("finish push = %#v", finished) - } - - // Queue auto-send: a new run flows into the same subscription. - dispatchStarted(sm, "run-2", "conversation-1") - second := receiveEventOfType(t, conn, "chat.event") - if second["type"] != "run_started" || second["run_id"] != "run-2" { - t.Fatalf("handoff push = %#v, want run_started run-2", second) - } - dispatchToken(sm, "run-2", "conversation-1", "again") - tail := receiveEventOfType(t, conn, "chat.event") - if tail["run_id"] != "run-2" || tail["text"] != "again" { - t.Fatalf("handoff token = %#v", tail) - } -} - -func TestChatActivityBroadcastCarriesRunIDs(t *testing.T) { - t.Parallel() - - sm, _, conn, cleanup := newChatWebSocketTest(t) - defer cleanup() - - dispatchStarted(sm, "run-1", "conversation-1") - running := receiveEventOfType(t, conn, "chat.activity") - if running["running"] != true || running["run_id"] != "run-1" || running["conversation_id"] != "conversation-1" { - t.Fatalf("running activity = %#v", running) - } - - dispatchDone(sm, "run-1", "conversation-1") - idle := receiveEventOfType(t, conn, "chat.activity") - if idle["running"] != false || idle["conversation_id"] != "conversation-1" { - t.Fatalf("idle activity = %#v", idle) - } -} - -func TestChatCommandSubmitSeedsUserMessageAndDeliversEnvelope(t *testing.T) { - t.Parallel() - - sm, agentSession, conn, cleanup := newChatWebSocketTest(t) - defer cleanup() - - sendEnvelope(t, conn, "sub-1", "chat.subscribe", map[string]any{ - "conversation_id": "conversation-1", - }) - receiveEnvelopeWithID(t, conn, "sub-1") - - sendEnvelope(t, conn, "cmd-1", "chat.command", map[string]any{ - "type": "chat.submit", - "payload": map[string]any{ - "message": "hello agent", - "conversation_id": "conversation-1", - "client_request_id": "client-1", - }, - }) - answerChatRuntimeProbe(t, sm, agentSession) - - resp := decodePayload(t, receiveEnvelopeWithID(t, conn, "cmd-1")) - runID, _ := resp["run_id"].(string) - if runID == "" || resp["conversation_id"] != "conversation-1" { - t.Fatalf("command response = %#v", resp) - } - if seq, ok := resp["accepted_seq"].(float64); !ok || seq <= 0 { - t.Fatalf("accepted_seq = %#v, want > 0", resp["accepted_seq"]) - } - if resp["deduped"] != false { - t.Fatalf("first command deduped = %#v, want false", resp["deduped"]) - } - - seeded := receiveEventOfType(t, conn, "chat.event") - if seeded["type"] != "user_message" || - seeded["message"] != "hello agent" || - seeded["client_request_id"] != "client-1" || - seeded["run_id"] != runID { - t.Fatalf("seeded user_message = %#v", seeded) - } - - outbound := readOutboundEnvelope(t, agentSession) - command := outbound.GetChatCommand() - if command == nil || command.GetType() != "chat.submit" { - t.Fatalf("outbound payload = %#v, want chat.submit command", outbound.GetPayload()) - } - if command.GetRequest().GetMessage() != "hello agent" || - command.GetRequest().GetClientRequestId() != "client-1" { - t.Fatalf("chat command request = %#v", command.GetRequest()) - } - - // A retry with the same client_request_id returns the canonical run through - // the priority response path. It must neither probe nor dispatch nor seed a - // second user message. - sendEnvelope(t, conn, "cmd-2", "chat.command", map[string]any{ - "type": "chat.submit", - "payload": map[string]any{ - "message": "hello agent", - "conversation_id": "conversation-1", - "client_request_id": "client-1", - }, - }) - retry := decodePayload(t, receiveEnvelopeWithID(t, conn, "cmd-2")) - if retry["run_id"] != runID || retry["deduped"] != true { - t.Fatalf("deduplicated command response = %#v, want run %q", retry, runID) - } - select { - case duplicate := <-agentSession.Outbound(): - duplicate.Ack(nil) - t.Fatalf("deduplicated command dispatched another envelope: %#v", duplicate.GatewayEnvelope) - case <-time.After(50 * time.Millisecond): - } - replay := sm.SubscribeConversationStream("conversation-1", 0, "") - userMessages := 0 - for _, event := range replay.Events { - if event.Type == "user_message" { - userMessages++ - } - } - replay.Cleanup() - if userMessages != 1 { - t.Fatalf("deduplicated command seeded %d user messages, want 1", userMessages) - } - - // The agent's user_message echo is swallowed: the next stream event after - // run start must not duplicate the seeded message. - dispatchStarted(sm, runID, "conversation-1") - startedPush := receiveEventOfType(t, conn, "chat.event") - if startedPush["type"] != "run_started" { - t.Fatalf("post-start push = %#v", startedPush) - } - echoData, _ := json.Marshal(map[string]any{"message": "hello agent"}) - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: runID, - Payload: &gatewayv1.AgentEnvelope_ChatEvent{ - ChatEvent: &gatewayv1.ChatEvent{ - Type: gatewayv1.ChatEvent_USER_MESSAGE, - ConversationId: "conversation-1", - Data: string(echoData), - }, - }, - }) - dispatchToken(sm, runID, "conversation-1", "reply") - next := receiveEventOfType(t, conn, "chat.event") - if next["type"] != "token" || next["text"] != "reply" { - t.Fatalf("expected token after swallowed echo, got %#v", next) - } -} - -func TestChatPrepareProbesAgentAndReturnsStatus(t *testing.T) { - t.Parallel() - - sm, agentSession, conn, cleanup := newChatWebSocketTest(t) - defer cleanup() - - sendEnvelope(t, conn, "prepare-1", "chat.prepare", map[string]any{ - "reason": "foreground", - }) - answerChatRuntimeProbe(t, sm, agentSession) - - status := decodePayload(t, receiveEnvelopeWithID(t, conn, "prepare-1")) - if status["online"] != true || status["agent_ready"] != true { - t.Fatalf("chat.prepare status = %#v", status) - } - if status["chat_runtime_ready"] != false { - t.Fatalf("chat.prepare runtime readiness = %#v, want false without heartbeat", status) - } - - // The immediately following command reuses the session-bound successful - // prepare result. Its next native envelope must be the command itself, not a - // second Ping that adds another round trip to the normal send path. - sendEnvelope(t, conn, "cmd-after-prepare", "chat.command", map[string]any{ - "type": "chat.submit", - "payload": map[string]any{ - "message": "hello after prepare", - "conversation_id": "conversation-prepare", - "client_request_id": "client-prepare", - }, - }) - accepted := decodePayload(t, receiveEnvelopeWithID(t, conn, "cmd-after-prepare")) - if runID, ok := accepted["run_id"].(string); !ok || runID == "" { - t.Fatalf("command after prepare response = %#v", accepted) - } - command := readOutboundEnvelope(t, agentSession) - if command.GetChatCommand() == nil || command.GetPing() != nil { - t.Fatalf("command after fresh prepare = %#v, want ChatCommand without another Ping", command) - } -} - -func TestChatCancelKeepsRunAliveUntilAgentConfirms(t *testing.T) { - t.Parallel() - - sm, agentSession, conn, cleanup := newChatWebSocketTest(t) - defer cleanup() - - sendEnvelope(t, conn, "sub-1", "chat.subscribe", map[string]any{ - "conversation_id": "conversation-1", - }) - receiveEnvelopeWithID(t, conn, "sub-1") - - dispatchStarted(sm, "run-1", "conversation-1") - receiveEventOfType(t, conn, "chat.event") - - sendEnvelope(t, conn, "cancel-1", "chat.command", map[string]any{ - "type": "chat.cancel", - "payload": map[string]any{"conversation_id": "conversation-1"}, - }) - - // The gateway blocks the response on agent delivery; ack it first. - outbound := readOutboundEnvelope(t, agentSession) - if outbound.GetChatCommand().GetType() != "chat.cancel" { - t.Fatalf("outbound cancel = %#v", outbound.GetPayload()) - } - - // The run is NOT terminalized by the gateway: activity flips to - // cancelling and the agent's terminal signal wins. The response and the - // activity push race on the outbox, so collect both order-independently. - var cancelling map[string]any - var resp map[string]any - for attempt := 0; attempt < 16 && (cancelling == nil || resp == nil); attempt++ { - env := receiveEnvelope(t, conn) - switch { - case env.Type == "chat.activity": - payload := decodePayload(t, env) - if payload["state"] == "cancelling" { - cancelling = payload - } - case env.ID == "cancel-1": - resp = decodePayload(t, env) - } - } - if cancelling == nil || cancelling["running"] != true { - t.Fatalf("cancelling activity = %#v", cancelling) - } - if resp == nil || resp["ok"] != true || resp["run_id"] != "run-1" { - t.Fatalf("cancel response = %#v", resp) - } - - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "run-1", - Payload: &gatewayv1.AgentEnvelope_ChatControl{ - ChatControl: &gatewayv1.ChatControlEvent{ - RequestId: "run-1", - ConversationId: "conversation-1", - Type: "cancelled", - State: "cancelled", - }, - }, - }) - finished := receiveEventOfType(t, conn, "chat.event") - if finished["type"] != "run_finished" || finished["status"] != "cancelled" { - t.Fatalf("cancel finish = %#v", finished) - } -} - -func TestChatSubscribeResumesWithAfterSeq(t *testing.T) { - t.Parallel() - - sm, _, conn, cleanup := newChatWebSocketTest(t) - defer cleanup() - - dispatchStarted(sm, "run-1", "conversation-1") - dispatchToken(sm, "run-1", "conversation-1", "one") - dispatchToken(sm, "run-1", "conversation-1", "two") - - sendEnvelope(t, conn, "sub-1", "chat.subscribe", map[string]any{ - "conversation_id": "conversation-1", - }) - first := decodePayload(t, receiveEnvelopeWithID(t, conn, "sub-1")) - events, _ := first["events"].([]any) - if len(events) != 3 { - t.Fatalf("full replay = %d events, want 3", len(events)) - } - epoch, _ := first["stream_epoch"].(string) - latest, _ := first["latest_seq"].(float64) - - sendEnvelope(t, conn, "sub-2", "chat.subscribe", map[string]any{ - "conversation_id": "conversation-1", - "after_seq": latest - 1, - "stream_epoch": epoch, - }) - resumed := decodePayload(t, receiveEnvelopeWithID(t, conn, "sub-2")) - resumedEvents, _ := resumed["events"].([]any) - if resumed["reset"] != false || len(resumedEvents) != 1 { - t.Fatalf("resume = reset:%v events:%d, want reset:false events:1", resumed["reset"], len(resumedEvents)) - } - - sendEnvelope(t, conn, "sub-3", "chat.subscribe", map[string]any{ - "conversation_id": "conversation-1", - "after_seq": latest, - "stream_epoch": "stale-epoch", - }) - mismatched := decodePayload(t, receiveEnvelopeWithID(t, conn, "sub-3")) - if mismatched["reset"] != true { - t.Fatalf("epoch mismatch must reset, got %#v", mismatched) - } -} diff --git a/crates/agent-gateway/test/websocket/git_test.go b/crates/agent-gateway/test/websocket/git_test.go deleted file mode 100644 index fa34b1538..000000000 --- a/crates/agent-gateway/test/websocket/git_test.go +++ /dev/null @@ -1,236 +0,0 @@ -package websocket_test - -import ( - "encoding/json" - "strings" - "testing" - "time" - - "github.com/gorilla/websocket" - - "github.com/liveagent/agent-gateway/internal/config" - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" - "github.com/liveagent/agent-gateway/internal/server" - "github.com/liveagent/agent-gateway/internal/session" -) - -func newGitWebSocketTest( - t *testing.T, - webGitEnabled bool, -) (*session.Manager, *session.AgentSession, *websocket.Conn, func()) { - t.Helper() - - sm := session.NewManager() - webGitSetting := "false" - if webGitEnabled { - webGitSetting = "true" - } - sm.ApplySettingsJSON(`{"remote":{"enableWebGit":` + webGitSetting + `}}`) - sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") - agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) - sm.SetSession(agentSession) - - handler := server.NewWebSocketServer(&config.Config{ - Token: "ws-token", - RequestTimeout: time.Second, - }, sm) - conn, cleanup := dialGatewayWebSocket(t, handler) - authWebSocket(t, conn, "ws-token") - return sm, agentSession, conn, cleanup -} - -func TestWebSocketGitRejectsWriteRequestsWhenDisabled(t *testing.T) { - t.Parallel() - - _, _, conn, cleanup := newGitWebSocketTest(t, false) - defer cleanup() - - cases := []struct { - id string - reqType string - args map[string]any - }{ - { - id: "git-stage-disabled", - reqType: "git.stage", - args: map[string]any{ - "path": "src/main.rs", - }, - }, - { - id: "git-init-disabled", - reqType: "git.init", - args: map[string]any{ - "branch": "main", - }, - }, - {id: "git-stage-all-disabled", reqType: "git.stage_all"}, - {id: "git-unstage-all-disabled", reqType: "git.unstage_all"}, - {id: "git-discard-all-disabled", reqType: "git.discard_all"}, - } - for _, tc := range cases { - sendEnvelope(t, conn, tc.id, tc.reqType, map[string]any{ - "workdir": "/workspace/project", - "args": tc.args, - }) - - env := receiveEnvelope(t, conn) - if env.ID != tc.id || env.Type != "error" { - t.Fatalf("%s disabled response = %#v, want error", tc.reqType, env) - } - if !strings.Contains(env.Error, "web git is disabled") { - t.Fatalf("%s disabled error = %q", tc.reqType, env.Error) - } - } -} - -func TestWebSocketGitAllowsReadRequestsWhenDisabled(t *testing.T) { - t.Parallel() - - sm, agentSession, conn, cleanup := newGitWebSocketTest(t, false) - defer cleanup() - - sendEnvelope(t, conn, "git-status-read", "git.status", map[string]any{ - "workdir": " /workspace/project ", - }) - outbound := readOutboundEnvelope(t, agentSession) - req := outbound.GetGitRequest() - if req == nil { - t.Fatalf("git.status outbound payload = %T, want GitRequest", outbound.GetPayload()) - } - if req.GetAction() != "status" || req.GetWorkdir() != "/workspace/project" || req.GetArgsJson() != "{}" { - t.Fatalf("git status request = %#v", req) - } - - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: outbound.GetRequestId(), - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.AgentEnvelope_GitResponse{ - GitResponse: &gatewayv1.GitResponse{ - Action: "status", - ResultJson: `{"status":"ready","repoRoot":"/workspace/project"}`, - }, - }, - }) - resp := receiveEnvelopeWithID(t, conn, "git-status-read") - if resp.Type != "response" { - t.Fatalf("git status response = %#v, want response", resp) - } - var payload map[string]any - if err := json.Unmarshal(resp.Payload, &payload); err != nil { - t.Fatalf("decode git status response: %v", err) - } - if payload["status"] != "ready" || payload["repoRoot"] != "/workspace/project" { - t.Fatalf("git status payload = %#v", payload) - } - - readCases := []struct { - id string - reqType string - action string - args map[string]any - result string - }{ - { - id: "git-commit-details-read", - reqType: "git.commit_details", - action: "commit_details", - args: map[string]any{ - "commit": "abc1234", - }, - result: `{"commit":{"sha":"abc1234","shortSha":"abc1234","subject":"subject"}}`, - }, - { - id: "git-compare-remote-read", - reqType: "git.compare_commit_with_remote", - action: "compare_commit_with_remote", - args: map[string]any{ - "commit": "def5678", - }, - result: `{"baseRef":"origin/main","headRef":"def5678","patch":""}`, - }, - } - for _, tc := range readCases { - sendEnvelope(t, conn, tc.id, tc.reqType, map[string]any{ - "workdir": " /workspace/project ", - "args": tc.args, - }) - outbound := readOutboundEnvelope(t, agentSession) - req := outbound.GetGitRequest() - if req == nil { - t.Fatalf("%s outbound payload = %T, want GitRequest", tc.reqType, outbound.GetPayload()) - } - if req.GetAction() != tc.action || req.GetWorkdir() != "/workspace/project" { - t.Fatalf("%s request = %#v", tc.reqType, req) - } - var args map[string]any - if err := json.Unmarshal([]byte(req.GetArgsJson()), &args); err != nil { - t.Fatalf("decode %s args: %v", tc.reqType, err) - } - if args["commit"] != tc.args["commit"] { - t.Fatalf("%s args = %#v", tc.reqType, args) - } - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: outbound.GetRequestId(), - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.AgentEnvelope_GitResponse{ - GitResponse: &gatewayv1.GitResponse{ - Action: tc.action, - ResultJson: tc.result, - }, - }, - }) - resp := receiveEnvelopeWithID(t, conn, tc.id) - if resp.Type != "response" { - t.Fatalf("%s response = %#v, want response", tc.reqType, resp) - } - } -} - -func TestWebSocketGitForwardsWriteRequestsWhenEnabled(t *testing.T) { - t.Parallel() - - sm, agentSession, conn, cleanup := newGitWebSocketTest(t, true) - defer cleanup() - - sendEnvelope(t, conn, "git-create-enabled", "git.create_branch", map[string]any{ - "workdir": " /workspace/project ", - "args": map[string]any{ - "branch": "feature/git-review", - "startPoint": "abc1234", - }, - }) - outbound := readOutboundEnvelope(t, agentSession) - req := outbound.GetGitRequest() - if req == nil { - t.Fatalf("git.create_branch outbound payload = %T, want GitRequest", outbound.GetPayload()) - } - if req.GetAction() != "create_branch" || req.GetWorkdir() != "/workspace/project" { - t.Fatalf("git create_branch request = %#v", req) - } - var args map[string]any - if err := json.Unmarshal([]byte(req.GetArgsJson()), &args); err != nil { - t.Fatalf("decode git args: %v", err) - } - if args["branch"] != "feature/git-review" { - t.Fatalf("git create_branch args = %#v", args) - } - if args["startPoint"] != "abc1234" { - t.Fatalf("git create_branch args = %#v", args) - } - - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: outbound.GetRequestId(), - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.AgentEnvelope_GitResponse{ - GitResponse: &gatewayv1.GitResponse{ - Action: "create_branch", - ResultJson: `{"ok":true,"message":"created"}`, - }, - }, - }) - resp := receiveEnvelopeWithID(t, conn, "git-create-enabled") - if resp.Type != "response" { - t.Fatalf("git create response = %#v, want response", resp) - } -} diff --git a/crates/agent-gateway/test/websocket/liveness_test.go b/crates/agent-gateway/test/websocket/liveness_test.go deleted file mode 100644 index e7d7d15e3..000000000 --- a/crates/agent-gateway/test/websocket/liveness_test.go +++ /dev/null @@ -1,145 +0,0 @@ -package websocket_test - -import ( - "encoding/json" - "testing" - "time" - - "github.com/gorilla/websocket" - "github.com/liveagent/agent-gateway/internal/config" - "github.com/liveagent/agent-gateway/internal/server" - "github.com/liveagent/agent-gateway/internal/session" -) - -// idleTimeout = 3*100ms + 200ms = 500ms for every test in this file. -func newLivenessWebSocketTest(t *testing.T) (*session.Manager, *websocket.Conn, func()) { - t.Helper() - - sm := session.NewManager() - handler := server.NewWebSocketServer(&config.Config{ - Token: "ws-token", - RequestTimeout: time.Second, - WebSocketHeartbeatPeriod: 100 * time.Millisecond, - WebSocketHeartbeatGrace: 200 * time.Millisecond, - WebSocketWriteTimeout: time.Second, - }, sm) - conn, cleanup := dialGatewayWebSocket(t, handler) - authWebSocket(t, conn, "ws-token") - return sm, conn, cleanup -} - -func receiveStatusEvent(t *testing.T, conn *websocket.Conn) map[string]any { - t.Helper() - for attempt := 0; attempt < 64; attempt++ { - if err := conn.SetReadDeadline(time.Now().Add(time.Second)); err != nil { - t.Fatalf("set websocket read deadline: %v", err) - } - var env wsEnvelope - if err := conn.ReadJSON(&env); err != nil { - t.Fatalf("receive status.event: %v", err) - } - if env.Type != "status.event" { - continue - } - payload := map[string]any{} - if len(env.Payload) > 0 { - if err := json.Unmarshal(env.Payload, &payload); err != nil { - t.Fatalf("decode status.event payload: %v", err) - } - } - return payload - } - t.Fatal("timed out waiting for status.event") - return nil -} - -// A frozen background tab stops running JS — no JSON pongs, no requests — but -// the browser's network stack keeps answering protocol pings. Gorilla's -// default ping handler reproduces exactly that, so a client that only ever -// reads must survive well past the idle timeout. -func TestProtocolPongOnlyClientSurvivesIdleTimeout(t *testing.T) { - t.Parallel() - - _, conn, cleanup := newLivenessWebSocketTest(t) - defer cleanup() - - silentUntil := time.Now().Add(2 * time.Second) // 4x the 500ms idle timeout - for time.Now().Before(silentUntil) { - if err := conn.SetReadDeadline(time.Now().Add(3 * time.Second)); err != nil { - t.Fatalf("set websocket read deadline: %v", err) - } - var env wsEnvelope - if err := conn.ReadJSON(&env); err != nil { - t.Fatalf("connection dropped during the silent window: %v", err) - } - } - - sendEnvelope(t, conn, "status-1", "status.get", map[string]any{}) - env := receiveEnvelopeWithID(t, conn, "status-1") - if env.Type != "response" { - t.Fatalf("status.get after silent window = %#v, want response", env) - } -} - -// A client that answers nothing — not even protocol pings — is a dead link -// and must still be reaped promptly. -func TestUnresponsiveClientIsEvictedAfterIdleTimeout(t *testing.T) { - t.Parallel() - - _, conn, cleanup := newLivenessWebSocketTest(t) - defer cleanup() - - conn.SetPingHandler(func(string) error { return nil }) - - evicted := make(chan struct{}, 1) - go func() { - for { - _ = conn.SetReadDeadline(time.Now().Add(5 * time.Second)) - var env wsEnvelope - if err := conn.ReadJSON(&env); err != nil { - evicted <- struct{}{} - return - } - } - }() - - select { - case <-evicted: - case <-time.After(3 * time.Second): - t.Fatal("server did not evict a client that stopped answering pings") - } -} - -// Agent connect/disconnect must reach web clients as pushed status.event -// frames (the client's poll is only a slow fallback). -func TestStatusEventPushedOnAgentSessionChanges(t *testing.T) { - t.Parallel() - - sm, conn, cleanup := newLivenessWebSocketTest(t) - defer cleanup() - - initial := receiveStatusEvent(t, conn) - if online, _ := initial["online"].(bool); online { - t.Fatalf("initial replayed status.event online = true, want false") - } - - sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") - agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) - sm.SetSession(agentSession) - connected := receiveStatusEvent(t, conn) - if online, _ := connected["online"].(bool); !online { - t.Fatalf("status.event after SetSession = %#v, want online=true", connected) - } - - sm.ClearSession(agentSession) - deadline := time.Now().Add(2 * time.Second) - for { - got := receiveStatusEvent(t, conn) - if online, _ := got["online"].(bool); !online { - break - } - if time.Now().After(deadline) { - t.Fatal("status.event online=false not pushed after ClearSession") - } - } -} diff --git a/crates/agent-gateway/test/websocket/terminal_test.go b/crates/agent-gateway/test/websocket/terminal_test.go deleted file mode 100644 index 0bf27ded1..000000000 --- a/crates/agent-gateway/test/websocket/terminal_test.go +++ /dev/null @@ -1,973 +0,0 @@ -package websocket_test - -import ( - "encoding/binary" - "encoding/json" - "errors" - "net" - "strings" - "testing" - "time" - - "github.com/gorilla/websocket" - - "github.com/liveagent/agent-gateway/internal/config" - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" - "github.com/liveagent/agent-gateway/internal/server" - "github.com/liveagent/agent-gateway/internal/session" -) - -type terminalStreamHeader struct { - Kind string `json:"kind,omitempty"` - StreamID string `json:"streamId,omitempty"` - SessionID string `json:"sessionId,omitempty"` - ProjectPathKey string `json:"projectPathKey,omitempty"` - StartOffset uint64 `json:"startOffset,omitempty"` - EndOffset uint64 `json:"endOffset,omitempty"` - Cols uint32 `json:"cols,omitempty"` - Rows uint32 `json:"rows,omitempty"` - MaxBytes uint32 `json:"maxBytes,omitempty"` - Truncated bool `json:"truncated,omitempty"` - Error string `json:"error,omitempty"` - Session map[string]any `json:"session,omitempty"` -} - -func receiveNoTerminalEnvelope(t *testing.T, _ *websocket.Conn) { - t.Helper() - time.Sleep(150 * time.Millisecond) -} - -func assertTerminalEventKind(t *testing.T, env wsEnvelope, wantKind string) { - t.Helper() - if env.Type != "terminal.event" { - t.Fatalf("terminal event = %#v, want terminal.event", env) - } - var payload struct { - Kind string `json:"kind"` - Data string `json:"data"` - } - if err := json.Unmarshal(env.Payload, &payload); err != nil { - t.Fatalf("decode terminal event payload: %v", err) - } - if payload.Kind != wantKind { - t.Fatalf("terminal event kind = %q data = %q, want %q", payload.Kind, payload.Data, wantKind) - } -} - -func encodeTerminalStreamTestFrame(t *testing.T, header terminalStreamHeader, data []byte) []byte { - t.Helper() - header.Kind = strings.TrimSpace(header.Kind) - headerBytes, err := json.Marshal(header) - if err != nil { - t.Fatalf("encode terminal stream header: %v", err) - } - payload := make([]byte, 4+len(headerBytes)+len(data)) - payload[0] = 1 - payload[1] = 1 - binary.BigEndian.PutUint16(payload[2:4], uint16(len(headerBytes))) - copy(payload[4:], headerBytes) - copy(payload[4+len(headerBytes):], data) - return payload -} - -func decodeTerminalStreamTestFrame(t *testing.T, payload []byte) (terminalStreamHeader, []byte) { - t.Helper() - if len(payload) < 4 || payload[0] != 1 { - t.Fatalf("invalid terminal stream payload: %#v", payload) - } - headerLen := int(binary.BigEndian.Uint16(payload[2:4])) - if len(payload) < 4+headerLen { - t.Fatalf("truncated terminal stream payload: %#v", payload) - } - var header terminalStreamHeader - if err := json.Unmarshal(payload[4:4+headerLen], &header); err != nil { - t.Fatalf("decode terminal stream header: %v", err) - } - return header, payload[4+headerLen:] -} - -func dialTerminalStreamWebSocket(t *testing.T, sm *session.Manager) (*websocket.Conn, func()) { - t.Helper() - handler := server.NewTerminalWebSocketServer(&config.Config{ - Token: "ws-token", - RequestTimeout: time.Second, - WebSocketWriteTimeout: time.Second, - }, sm) - conn, cleanup := dialGatewayWebSocket(t, handler) - if err := conn.SetWriteDeadline(time.Now().Add(time.Second)); err != nil { - t.Fatalf("set terminal stream auth deadline: %v", err) - } - if err := conn.WriteJSON(map[string]any{"type": "auth", "token": "ws-token"}); err != nil { - t.Fatalf("send terminal stream auth: %v", err) - } - if err := conn.SetReadDeadline(time.Now().Add(time.Second)); err != nil { - t.Fatalf("set terminal stream auth read deadline: %v", err) - } - var authResp map[string]any - if err := conn.ReadJSON(&authResp); err != nil { - t.Fatalf("read terminal stream auth response: %v", err) - } - if authResp["type"] != "ready" { - t.Fatalf("terminal stream auth response = %#v", authResp) - } - return conn, cleanup -} - -func sendTerminalStreamFrame(t *testing.T, conn *websocket.Conn, header terminalStreamHeader, data []byte) { - t.Helper() - if err := conn.SetWriteDeadline(time.Now().Add(time.Second)); err != nil { - t.Fatalf("set terminal stream write deadline: %v", err) - } - if err := conn.WriteMessage(websocket.BinaryMessage, encodeTerminalStreamTestFrame(t, header, data)); err != nil { - t.Fatalf("send terminal stream frame: %v", err) - } -} - -func receiveTerminalStreamFrame(t *testing.T, conn *websocket.Conn) (terminalStreamHeader, []byte) { - t.Helper() - if err := conn.SetReadDeadline(time.Now().Add(time.Second)); err != nil { - t.Fatalf("set terminal stream read deadline: %v", err) - } - messageType, payload, err := conn.ReadMessage() - if err != nil { - t.Fatalf("read terminal stream frame: %v", err) - } - if messageType != websocket.BinaryMessage { - t.Fatalf("terminal stream message type = %d, want binary", messageType) - } - return decodeTerminalStreamTestFrame(t, payload) -} - -func receiveNoTerminalStreamFrame(t *testing.T, conn *websocket.Conn) { - t.Helper() - if err := conn.SetReadDeadline(time.Now().Add(150 * time.Millisecond)); err != nil { - t.Fatalf("set terminal stream short deadline: %v", err) - } - _, _, err := conn.ReadMessage() - if err == nil { - t.Fatal("unexpected terminal stream frame") - } - var netErr net.Error - if !errors.As(err, &netErr) || !netErr.Timeout() { - t.Fatalf("terminal stream read returned %v, want timeout", err) - } -} - -func readTerminalStreamOutbound(t *testing.T, ch <-chan *gatewayv1.TerminalStreamFrame) *gatewayv1.TerminalStreamFrame { - t.Helper() - select { - case frame := <-ch: - return frame - case <-time.After(time.Second): - t.Fatal("timed out waiting for terminal stream frame to reach agent") - return nil - } -} - -func newTerminalWebSocketTest( - t *testing.T, - webTerminalEnabled bool, -) (*session.Manager, *session.AgentSession, *websocket.Conn, func()) { - t.Helper() - return newTerminalWebSocketTestWithPermissions(t, webTerminalEnabled, false) -} - -func newTerminalWebSocketTestWithPermissions( - t *testing.T, - webTerminalEnabled bool, - webSshTerminalEnabled bool, -) (*session.Manager, *session.AgentSession, *websocket.Conn, func()) { - t.Helper() - - sm := session.NewManager() - webTerminalSetting := "false" - if webTerminalEnabled { - webTerminalSetting = "true" - } - webSshTerminalSetting := "false" - if webSshTerminalEnabled { - webSshTerminalSetting = "true" - } - sm.ApplySettingsJSON(`{"remote":{"enableWebTerminal":` + webTerminalSetting + `,"enableWebSshTerminal":` + webSshTerminalSetting + `}}`) - sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") - agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) - sm.SetSession(agentSession) - - handler := server.NewWebSocketServer(&config.Config{ - Token: "ws-token", - RequestTimeout: time.Second, - }, sm) - conn, cleanup := dialGatewayWebSocket(t, handler) - authWebSocket(t, conn, "ws-token") - return sm, agentSession, conn, cleanup -} - -func TestWebSocketSshTerminalPermissionIsIndependentFromLocalTerminal(t *testing.T) { - t.Parallel() - - sm, agentSession, conn, cleanup := newTerminalWebSocketTestWithPermissions(t, false, true) - defer cleanup() - - sendEnvelope(t, conn, "terminal-create-local-disabled", "terminal.create", map[string]any{ - "cwd": "/workspace/project", - "project_path_key": "/workspace/project", - }) - localResponse := receiveEnvelope(t, conn) - if localResponse.ID != "terminal-create-local-disabled" || localResponse.Type != "error" { - t.Fatalf("local terminal disabled response = %#v, want error", localResponse) - } - if !strings.Contains(localResponse.Error, "web terminal is disabled") { - t.Fatalf("local terminal disabled error = %q", localResponse.Error) - } - - sendEnvelope(t, conn, "terminal-create-ssh-enabled", "terminal.create_ssh", map[string]any{ - "cwd": " /workspace/project ", - "project_path_key": " /workspace/project ", - "ssh_host_id": " prod ", - "title": " Prod SSH ", - "cols": 120, - "rows": 32, - }) - outbound := readOutboundEnvelope(t, agentSession) - req := outbound.GetTerminalRequest() - if req == nil { - t.Fatalf("terminal.create_ssh outbound payload = %T, want TerminalRequest", outbound.GetPayload()) - } - if req.GetAction() != "create_ssh" || - req.GetCwd() != "/workspace/project" || - req.GetProjectPathKey() != "/workspace/project" || - req.GetSshHostId() != "prod" || - req.GetTitle() != "Prod SSH" || - req.GetCols() != 120 || - req.GetRows() != 32 { - t.Fatalf("terminal create_ssh request = %#v", req) - } - - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: outbound.GetRequestId(), - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.AgentEnvelope_TerminalResponse{ - TerminalResponse: &gatewayv1.TerminalResponse{ - Action: "create_ssh", - Session: &gatewayv1.TerminalSession{ - Id: "ssh-1", - ProjectPathKey: "/workspace/project", - Cwd: "/workspace/project", - Shell: "ssh", - Title: "Prod SSH", - Kind: "ssh", - Cols: 120, - Rows: 32, - CreatedAt: 1, - UpdatedAt: 2, - Running: true, - Ssh: &gatewayv1.TerminalSshMetadata{ - HostId: "prod", - HostName: "Production", - Username: "deploy", - Host: "prod.example.com", - Port: 22, - AuthType: "privateKey", - }, - }, - }, - }, - }) - createResponse := receiveEnvelopeWithID(t, conn, "terminal-create-ssh-enabled") - if createResponse.Type != "response" { - t.Fatalf("terminal create_ssh response = %#v, want response", createResponse) - } - var createPayload struct { - Session map[string]any `json:"session"` - } - if err := json.Unmarshal(createResponse.Payload, &createPayload); err != nil { - t.Fatalf("decode create_ssh response: %v", err) - } - if createPayload.Session["kind"] != "ssh" || createPayload.Session["pid"] != nil { - t.Fatalf("create_ssh session payload = %#v, want ssh with nil pid", createPayload.Session) - } - sshPayload, ok := createPayload.Session["ssh"].(map[string]any) - if !ok || sshPayload["host_id"] != "prod" || sshPayload["auth_type"] != "privateKey" { - t.Fatalf("create_ssh ssh metadata = %#v", createPayload.Session["ssh"]) - } - - _ = agentSession -} - -func TestWebSocketSshTerminalCreateRejectedWithoutSshPermission(t *testing.T) { - t.Parallel() - - _, _, conn, cleanup := newTerminalWebSocketTestWithPermissions(t, true, false) - defer cleanup() - - sendEnvelope(t, conn, "terminal-create-ssh-disabled", "terminal.create_ssh", map[string]any{ - "cwd": "/workspace/project", - "project_path_key": "/workspace/project", - "ssh_host_id": "prod", - }) - - env := receiveEnvelope(t, conn) - if env.ID != "terminal-create-ssh-disabled" || env.Type != "error" { - t.Fatalf("ssh terminal disabled response = %#v, want error", env) - } - if !strings.Contains(env.Error, "web SSH terminal is disabled") { - t.Fatalf("ssh terminal disabled error = %q", env.Error) - } -} - -func TestWebSocketTerminalListFiltersLocalSessionsWhenOnlySshEnabled(t *testing.T) { - t.Parallel() - - sm, agentSession, conn, cleanup := newTerminalWebSocketTestWithPermissions(t, false, true) - defer cleanup() - - sendEnvelope(t, conn, "terminal-list-ssh-only", "terminal.list", map[string]any{ - "project_path_key": "/workspace/project", - }) - outbound := readOutboundEnvelope(t, agentSession) - req := outbound.GetTerminalRequest() - if req == nil || req.GetAction() != "list" { - t.Fatalf("terminal list outbound payload = %#v, want list request", outbound.GetPayload()) - } - - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: outbound.GetRequestId(), - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.AgentEnvelope_TerminalResponse{ - TerminalResponse: &gatewayv1.TerminalResponse{ - Action: "list", - Sessions: []*gatewayv1.TerminalSession{ - { - Id: "local-1", - ProjectPathKey: "/workspace/project", - Cwd: "/workspace/project", - Title: "Local", - Kind: "local", - CreatedAt: 1, - UpdatedAt: 1, - Running: true, - }, - { - Id: "ssh-1", - ProjectPathKey: "/workspace/project", - Cwd: "/workspace/project", - Shell: "ssh", - Title: "Production", - Kind: "ssh", - CreatedAt: 2, - UpdatedAt: 2, - Running: true, - Ssh: &gatewayv1.TerminalSshMetadata{ - HostId: "prod", - HostName: "Production", - Username: "deploy", - Host: "prod.example.com", - Port: 22, - AuthType: "password", - }, - }, - }, - }, - }, - }) - response := receiveEnvelopeWithID(t, conn, "terminal-list-ssh-only") - if response.Type != "response" { - t.Fatalf("terminal ssh-only list response = %#v, want response", response) - } - var payload struct { - Sessions []map[string]any `json:"sessions"` - } - if err := json.Unmarshal(response.Payload, &payload); err != nil { - t.Fatalf("decode ssh-only list response: %v", err) - } - if len(payload.Sessions) != 1 || - payload.Sessions[0]["id"] != "ssh-1" || - payload.Sessions[0]["kind"] != "ssh" { - t.Fatalf("ssh-only list sessions = %#v, want only ssh-1", payload.Sessions) - } -} - -func TestWebSocketTerminalListMergesCachedSshSessions(t *testing.T) { - t.Parallel() - - sm, agentSession, conn, cleanup := newTerminalWebSocketTestWithPermissions(t, false, true) - defer cleanup() - - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "event-created-cached-ssh", - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.AgentEnvelope_TerminalEvent{ - TerminalEvent: &gatewayv1.TerminalEvent{ - Kind: "created", - SessionId: "ssh-1", - ProjectPathKey: "/workspace/project", - Session: &gatewayv1.TerminalSession{ - Id: "ssh-1", - ProjectPathKey: "/workspace/project", - Cwd: "/workspace/project", - Shell: "ssh", - Title: "Production", - Kind: "ssh", - CreatedAt: 2, - UpdatedAt: 2, - Running: true, - Ssh: &gatewayv1.TerminalSshMetadata{ - HostId: "prod", - HostName: "Production", - Username: "deploy", - Host: "prod.example.com", - Port: 22, - AuthType: "password", - Status: "connected", - }, - }, - }, - }, - }) - createdEvent := receiveEnvelope(t, conn) - if createdEvent.Type != "terminal.event" { - t.Fatalf("cached ssh created event = %#v, want terminal.event", createdEvent) - } - - sendEnvelope(t, conn, "terminal-list-merge-cached-ssh", "terminal.list", map[string]any{}) - outbound := readOutboundEnvelope(t, agentSession) - req := outbound.GetTerminalRequest() - if req == nil || req.GetAction() != "list" { - t.Fatalf("terminal list outbound payload = %#v, want list request", outbound.GetPayload()) - } - - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: outbound.GetRequestId(), - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.AgentEnvelope_TerminalResponse{ - TerminalResponse: &gatewayv1.TerminalResponse{ - Action: "list", - Sessions: []*gatewayv1.TerminalSession{}, - }, - }, - }) - response := receiveEnvelopeWithID(t, conn, "terminal-list-merge-cached-ssh") - if response.Type != "response" { - t.Fatalf("terminal cached ssh list response = %#v, want response", response) - } - var payload struct { - Sessions []map[string]any `json:"sessions"` - } - if err := json.Unmarshal(response.Payload, &payload); err != nil { - t.Fatalf("decode cached ssh list response: %v", err) - } - if len(payload.Sessions) != 1 || - payload.Sessions[0]["id"] != "ssh-1" || - payload.Sessions[0]["kind"] != "ssh" { - t.Fatalf("cached ssh list sessions = %#v, want ssh-1", payload.Sessions) - } -} - -func TestWebSocketTerminalRejectsInteractiveRequestsWhenDisabled(t *testing.T) { - t.Parallel() - - _, _, conn, cleanup := newTerminalWebSocketTest(t, false) - defer cleanup() - - sendEnvelope(t, conn, "terminal-create-disabled", "terminal.create", map[string]any{ - "cwd": "/workspace/project", - "project_path_key": "/workspace/project", - }) - - env := receiveEnvelope(t, conn) - if env.ID != "terminal-create-disabled" || env.Type != "error" { - t.Fatalf("terminal disabled response = %#v, want error", env) - } - if !strings.Contains(env.Error, "web terminal is disabled") { - t.Fatalf("terminal disabled error = %q", env.Error) - } -} - -func TestWebSocketTerminalRejectsProjectCleanupRequestsWhenDisabled(t *testing.T) { - t.Parallel() - - _, _, conn, cleanup := newTerminalWebSocketTest(t, false) - defer cleanup() - - sendEnvelope(t, conn, "terminal-list-disabled", "terminal.list", map[string]any{ - "project_path_key": " /workspace/project ", - }) - listResponse := receiveEnvelope(t, conn) - if listResponse.ID != "terminal-list-disabled" || listResponse.Type != "error" { - t.Fatalf("terminal list response = %#v", listResponse) - } - if !strings.Contains(listResponse.Error, "web terminal is disabled") { - t.Fatalf("terminal list disabled error = %q", listResponse.Error) - } - - sendEnvelope(t, conn, "terminal-close-project-disabled", "terminal.close_project", map[string]any{ - "project_path_key": " /workspace/project ", - }) - closeResponse := receiveEnvelope(t, conn) - if closeResponse.ID != "terminal-close-project-disabled" || closeResponse.Type != "error" { - t.Fatalf("terminal close_project response = %#v", closeResponse) - } - if !strings.Contains(closeResponse.Error, "web terminal is disabled") { - t.Fatalf("terminal close_project disabled error = %q", closeResponse.Error) - } -} - -func TestWebSocketSettingsGetEnablesTerminalListAfterRefresh(t *testing.T) { - t.Parallel() - - sm := session.NewManager() - sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") - agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) - sm.SetSession(agentSession) - - handler := server.NewWebSocketServer(&config.Config{ - Token: "ws-token", - RequestTimeout: time.Second, - }, sm) - conn, cleanup := dialGatewayWebSocket(t, handler) - defer cleanup() - authWebSocket(t, conn, "ws-token") - - sendEnvelope(t, conn, "terminal-list-before-settings", "terminal.list", map[string]any{ - "project_path_key": "/workspace/project", - }) - beforeSettings := receiveEnvelope(t, conn) - if beforeSettings.ID != "terminal-list-before-settings" || beforeSettings.Type != "error" { - t.Fatalf("terminal list before settings = %#v, want disabled error", beforeSettings) - } - - sendEnvelope(t, conn, "settings-get", "settings.get", map[string]any{}) - settingsReq := readOutboundEnvelope(t, agentSession) - if settingsReq.GetSettingsGet() == nil { - t.Fatalf("settings.get outbound payload = %T, want SettingsGetRequest", settingsReq.GetPayload()) - } - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: settingsReq.GetRequestId(), - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.AgentEnvelope_SettingsGetResp{ - SettingsGetResp: &gatewayv1.SettingsGetResponse{ - SettingsJson: `{"remote":{"enableWebTerminal":true}}`, - }, - }, - }) - settingsResp := receiveEnvelopeWithID(t, conn, "settings-get") - if settingsResp.Type != "response" { - t.Fatalf("settings.get response = %#v, want response", settingsResp) - } - - sendEnvelope(t, conn, "terminal-list-after-settings", "terminal.list", map[string]any{ - "project_path_key": "/workspace/project", - }) - terminalReq := readOutboundEnvelope(t, agentSession) - req := terminalReq.GetTerminalRequest() - if req == nil { - t.Fatalf("terminal.list outbound payload = %T, want TerminalRequest", terminalReq.GetPayload()) - } - if req.GetAction() != "list" || req.GetProjectPathKey() != "/workspace/project" { - t.Fatalf("terminal list request after settings = %#v", req) - } - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: terminalReq.GetRequestId(), - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.AgentEnvelope_TerminalResponse{ - TerminalResponse: &gatewayv1.TerminalResponse{ - Action: "list", - Sessions: []*gatewayv1.TerminalSession{ - { - Id: "terminal-1", - ProjectPathKey: "/workspace/project", - Cwd: "/workspace/project", - Title: "Terminal 1", - CreatedAt: 1, - UpdatedAt: 1, - Running: true, - }, - }, - }, - }, - }) - terminalResp := receiveEnvelopeWithID(t, conn, "terminal-list-after-settings") - if terminalResp.Type != "response" { - t.Fatalf("terminal list after settings response = %#v, want response", terminalResp) - } -} - -func TestWebSocketTerminalListCanBootstrapAllSessions(t *testing.T) { - t.Parallel() - - sm, agentSession, conn, cleanup := newTerminalWebSocketTest(t, true) - defer cleanup() - - sendEnvelope(t, conn, "terminal-list-all", "terminal.list", map[string]any{}) - terminalReq := readOutboundEnvelope(t, agentSession) - req := terminalReq.GetTerminalRequest() - if req == nil { - t.Fatalf("terminal.list outbound payload = %T, want TerminalRequest", terminalReq.GetPayload()) - } - if req.GetAction() != "list" || req.GetProjectPathKey() != "" { - t.Fatalf("terminal list all request = %#v", req) - } - - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: terminalReq.GetRequestId(), - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.AgentEnvelope_TerminalResponse{ - TerminalResponse: &gatewayv1.TerminalResponse{ - Action: "list", - Sessions: []*gatewayv1.TerminalSession{ - { - Id: "terminal-1", - ProjectPathKey: "/workspace/project-a", - Cwd: "/workspace/project-a", - Title: "Terminal 1", - CreatedAt: 1, - UpdatedAt: 1, - Running: true, - }, - { - Id: "terminal-2", - ProjectPathKey: "/workspace/project-b", - Cwd: "/workspace/project-b", - Title: "Terminal 2", - CreatedAt: 2, - UpdatedAt: 2, - Running: true, - }, - }, - }, - }, - }) - - terminalResp := receiveEnvelopeWithID(t, conn, "terminal-list-all") - if terminalResp.Type != "response" { - t.Fatalf("terminal list all response = %#v, want response", terminalResp) - } - var payload struct { - Sessions []map[string]any `json:"sessions"` - } - if err := json.Unmarshal(terminalResp.Payload, &payload); err != nil { - t.Fatalf("decode terminal list all response: %v", err) - } - if len(payload.Sessions) != 2 { - t.Fatalf("terminal list all sessions = %#v, want 2 sessions", payload.Sessions) - } -} - -func TestWebSocketTerminalReplaysCachedSessionsAfterAuth(t *testing.T) { - t.Parallel() - - sm := session.NewManager() - sm.ApplySettingsJSON(`{"remote":{"enableWebTerminal":true}}`) - sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") - agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) - sm.SetSession(agentSession) - - handler := server.NewWebSocketServer(&config.Config{ - Token: "ws-token", - RequestTimeout: time.Second, - }, sm) - conn1, cleanup1 := dialGatewayWebSocket(t, handler) - defer cleanup1() - authWebSocket(t, conn1, "ws-token") - - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "event-created-replay", - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.AgentEnvelope_TerminalEvent{ - TerminalEvent: &gatewayv1.TerminalEvent{ - Kind: "created", - SessionId: "terminal-1", - ProjectPathKey: "/workspace/project", - Session: &gatewayv1.TerminalSession{ - Id: "terminal-1", - ProjectPathKey: "/workspace/project", - Cwd: "/workspace/project", - Title: "Terminal 1", - CreatedAt: 1, - UpdatedAt: 1, - Running: true, - }, - }, - }, - }) - firstEvent := receiveEnvelope(t, conn1) - if firstEvent.Type != "terminal.event" { - t.Fatalf("terminal created event = %#v, want terminal.event", firstEvent) - } - - conn2, cleanup2 := dialGatewayWebSocket(t, handler) - defer cleanup2() - authWebSocket(t, conn2, "ws-token") - replayedEvent := receiveEnvelope(t, conn2) - if replayedEvent.Type != "terminal.event" { - t.Fatalf("terminal replay event = %#v, want terminal.event", replayedEvent) - } - var payload struct { - Kind string `json:"kind"` - SessionID string `json:"session_id"` - Session map[string]any `json:"session"` - } - if err := json.Unmarshal(replayedEvent.Payload, &payload); err != nil { - t.Fatalf("decode terminal replay event: %v", err) - } - if payload.Kind != "created" || payload.SessionID != "terminal-1" { - t.Fatalf("terminal replay payload = %#v, want terminal-1 created", payload) - } -} - -func TestWebSocketTerminalForwardsControlRequestsWhenEnabled(t *testing.T) { - t.Parallel() - - sm, agentSession, conn, cleanup := newTerminalWebSocketTest(t, true) - defer cleanup() - - sendEnvelope(t, conn, "terminal-create-enabled", "terminal.create", map[string]any{ - "cwd": " /workspace/project ", - "project_path_key": " /workspace/project ", - "shell": " default ", - "title": " Dev ", - "cols": 120, - "rows": 32, - }) - outbound := readOutboundEnvelope(t, agentSession) - req := outbound.GetTerminalRequest() - if req == nil { - t.Fatalf("terminal create outbound payload = %T, want TerminalRequest", outbound.GetPayload()) - } - if req.GetAction() != "create" || - req.GetCwd() != "/workspace/project" || - req.GetProjectPathKey() != "/workspace/project" || - req.GetShell() != "default" || - req.GetTitle() != "Dev" || - req.GetCols() != 120 || - req.GetRows() != 32 { - t.Fatalf("terminal create request = %#v", req) - } - - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: outbound.GetRequestId(), - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.AgentEnvelope_TerminalResponse{ - TerminalResponse: &gatewayv1.TerminalResponse{ - Action: "create", - Session: &gatewayv1.TerminalSession{ - Id: "terminal-1", - ProjectPathKey: "/workspace/project", - Cwd: "/workspace/project", - Shell: "zsh", - Title: "Dev", - Cols: 120, - Rows: 32, - CreatedAt: 1, - UpdatedAt: 2, - Running: true, - }, - }, - }, - }) - response := receiveEnvelope(t, conn) - if response.ID != "terminal-create-enabled" || response.Type != "response" { - t.Fatalf("terminal create response = %#v", response) - } - var payload map[string]any - if err := json.Unmarshal(response.Payload, &payload); err != nil { - t.Fatalf("decode terminal create payload: %v", err) - } - sessionPayload, ok := payload["session"].(map[string]any) - if !ok || sessionPayload["id"] != "terminal-1" { - t.Fatalf("terminal create payload = %#v", payload) - } - - _ = sm -} - -func TestWebSocketTerminalEventsForwardMetadataOnly(t *testing.T) { - t.Parallel() - - sm, _, conn, cleanup := newTerminalWebSocketTest(t, true) - defer cleanup() - - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "event-created", - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.AgentEnvelope_TerminalEvent{ - TerminalEvent: &gatewayv1.TerminalEvent{ - Kind: "created", - SessionId: "terminal-1", - ProjectPathKey: "/workspace/project", - Session: &gatewayv1.TerminalSession{ - Id: "terminal-1", - ProjectPathKey: "/workspace/project", - Cwd: "/workspace/project", - Title: "Terminal 1", - Running: true, - }, - }, - }, - }) - createdEvent := receiveEnvelope(t, conn) - assertTerminalEventKind(t, createdEvent, "created") - - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: "event-output", - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.AgentEnvelope_TerminalEvent{ - TerminalEvent: &gatewayv1.TerminalEvent{ - Kind: "output", - SessionId: "terminal-1", - ProjectPathKey: "/workspace/project", - Data: []byte("legacy-hidden\n"), - OutputStartOffset: 0, - OutputEndOffset: 14, - }, - }, - }) - receiveNoTerminalEnvelope(t, conn) -} - -func TestTerminalStreamWebSocketForwardsBinaryFrames(t *testing.T) { - t.Parallel() - - sm := session.NewManager() - sm.ApplySettingsJSON(`{"remote":{"enableWebTerminal":true}}`) - toAgent := make(chan *gatewayv1.TerminalStreamFrame, 16) - cleanupAgent := sm.RegisterTerminalStreamToAgent(toAgent) - defer cleanupAgent() - - conn, cleanup := dialTerminalStreamWebSocket(t, sm) - defer cleanup() - - sendTerminalStreamFrame(t, conn, terminalStreamHeader{ - Kind: "attach", - StreamID: "stream-1", - SessionID: "terminal-1", - ProjectPathKey: "/workspace/project", - MaxBytes: 4096, - }, nil) - attach := readTerminalStreamOutbound(t, toAgent) - if attach.GetKind() != "attach" || - attach.GetStreamId() != "stream-1" || - attach.GetSessionId() != "terminal-1" || - attach.GetProjectPathKey() != "/workspace/project" || - attach.GetMaxBytes() != 4096 { - t.Fatalf("attach frame = %#v", attach) - } - - sm.BroadcastTerminalStreamFrame(&gatewayv1.TerminalStreamFrame{ - Kind: "snapshot", - StreamId: "stream-1", - SessionId: "terminal-1", - ProjectPathKey: "/workspace/project", - StartOffset: 7, - EndOffset: 13, - Data: []byte("ready\n"), - Session: &gatewayv1.TerminalSession{ - Id: "terminal-1", - ProjectPathKey: "/workspace/project", - Cwd: "/workspace/project", - Title: "Terminal 1", - Running: true, - }, - }) - snapshotHeader, snapshotData := receiveTerminalStreamFrame(t, conn) - if snapshotHeader.Kind != "snapshot" || - snapshotHeader.StreamID != "stream-1" || - snapshotHeader.StartOffset != 7 || - snapshotHeader.EndOffset != 13 || - string(snapshotData) != "ready\n" || - snapshotHeader.Session["id"] != "terminal-1" { - t.Fatalf("snapshot frame header=%#v data=%q", snapshotHeader, string(snapshotData)) - } - - sendTerminalStreamFrame(t, conn, terminalStreamHeader{ - Kind: "input", - StreamID: "stream-1", - SessionID: "terminal-1", - ProjectPathKey: "/workspace/project", - }, []byte("pwd\n")) - input := readTerminalStreamOutbound(t, toAgent) - if input.GetKind() != "input" || string(input.GetData()) != "pwd\n" { - t.Fatalf("input frame = %#v data=%q", input, string(input.GetData())) - } - - sendTerminalStreamFrame(t, conn, terminalStreamHeader{ - Kind: "resize", - StreamID: "stream-1", - SessionID: "terminal-1", - ProjectPathKey: "/workspace/project", - Cols: 132, - Rows: 40, - }, nil) - resize := readTerminalStreamOutbound(t, toAgent) - if resize.GetKind() != "resize" || resize.GetCols() != 132 || resize.GetRows() != 40 { - t.Fatalf("resize frame = %#v", resize) - } -} - -func TestTerminalStreamWebSocketOutputRequiresAttach(t *testing.T) { - t.Parallel() - - sm := session.NewManager() - sm.ApplySettingsJSON(`{"remote":{"enableWebTerminal":true}}`) - toAgent := make(chan *gatewayv1.TerminalStreamFrame, 16) - cleanupAgent := sm.RegisterTerminalStreamToAgent(toAgent) - defer cleanupAgent() - - conn, cleanup := dialTerminalStreamWebSocket(t, sm) - defer cleanup() - - sm.BroadcastTerminalStreamFrame(&gatewayv1.TerminalStreamFrame{ - Kind: "output", - SessionId: "terminal-1", - ProjectPathKey: "/workspace/project", - StartOffset: 0, - EndOffset: 7, - Data: []byte("hidden\n"), - }) - - sendTerminalStreamFrame(t, conn, terminalStreamHeader{ - Kind: "attach", - StreamID: "stream-1", - SessionID: "terminal-1", - ProjectPathKey: "/workspace/project", - }, nil) - _ = readTerminalStreamOutbound(t, toAgent) - - sm.BroadcastTerminalStreamFrame(&gatewayv1.TerminalStreamFrame{ - Kind: "output", - SessionId: "terminal-1", - ProjectPathKey: "/workspace/project", - StartOffset: 7, - EndOffset: 15, - Data: []byte("visible\n"), - }) - outputHeader, outputData := receiveTerminalStreamFrame(t, conn) - if outputHeader.Kind != "output" || - outputHeader.SessionID != "terminal-1" || - outputHeader.StartOffset != 7 || - outputHeader.EndOffset != 15 || - string(outputData) != "visible\n" { - t.Fatalf("output frame header=%#v data=%q", outputHeader, string(outputData)) - } - - sendTerminalStreamFrame(t, conn, terminalStreamHeader{ - Kind: "detach", - StreamID: "stream-1", - SessionID: "terminal-1", - ProjectPathKey: "/workspace/project", - }, nil) - detach := readTerminalStreamOutbound(t, toAgent) - if detach.GetKind() != "detach" { - t.Fatalf("detach frame = %#v", detach) - } - - sm.BroadcastTerminalStreamFrame(&gatewayv1.TerminalStreamFrame{ - Kind: "output", - SessionId: "terminal-1", - ProjectPathKey: "/workspace/project", - StartOffset: 15, - EndOffset: 28, - Data: []byte("hidden-again\n"), - }) - receiveNoTerminalStreamFrame(t, conn) -} diff --git a/crates/agent-gateway/test/websocket/v2_git_gating_test.go b/crates/agent-gateway/test/websocket/v2_git_gating_test.go new file mode 100644 index 000000000..ea0526e93 --- /dev/null +++ b/crates/agent-gateway/test/websocket/v2_git_gating_test.go @@ -0,0 +1,106 @@ +package websocket_test + +// v2 直通 git 门控:写操作受桌面端 Remote 设置 enable_web_git 门控(v1 +// websocket_git_handlers 同款语义),读操作始终放行。 + +import ( + "strings" + "testing" + + "github.com/gorilla/websocket" + + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" + gatewayv2 "github.com/liveagent/agent-gateway/internal/proto/v2" + "github.com/liveagent/agent-gateway/internal/protocol/pbws" + "github.com/liveagent/agent-gateway/internal/session" +) + +func newV2GitBrowserTest( + t *testing.T, + webGitEnabled bool, +) (*session.Manager, *session.AgentSession, *websocket.Conn, func()) { + t.Helper() + + sm := session.NewManager() + webGitSetting := "false" + if webGitEnabled { + webGitSetting = "true" + } + sm.ApplySettingsJSON(`{"remote":{"enableWebGit":` + webGitSetting + `}}`) + sm.RecordAuthentication("desktop-agent", "0.9.0", "session-1") + agentSession := session.NewAgentSession(sm.LatestAuthSnapshot()) + sm.SetSession(agentSession) + + handler := pbws.NewServer(newV2TestConfig(), sm).BrowserHandler() + conn, cleanup := dialV2(t, handler) + helloV2(t, conn, "ws-token") + return sm, agentSession, conn, cleanup +} + +func sendGitAgentRequest(t *testing.T, conn *websocket.Conn, id string, action string) { + t.Helper() + sendProtoFrame(t, conn, &gatewayv2.WebClientFrame{ + RequestId: id, + Payload: &gatewayv2.WebClientFrame_AgentRequest{ + AgentRequest: &gatewayv1.GatewayEnvelope{ + RequestId: id, + Payload: &gatewayv1.GatewayEnvelope_GitRequest{ + GitRequest: &gatewayv1.GitRequest{ + Action: action, + Workdir: "/workspace/project", + ArgsJson: "{}", + }, + }, + }, + }, + }) +} + +func TestV2GitRejectsWriteRequestsWhenDisabled(t *testing.T) { + t.Parallel() + + _, _, conn, cleanup := newV2GitBrowserTest(t, false) + defer cleanup() + + for _, action := range []string{"stage", "init", "stage_all", "unstage_all", "discard_all", "push", "commit"} { + id := "git-disabled-" + action + sendGitAgentRequest(t, conn, id, action) + + frame := receiveWebFrameWithID(t, conn, id) + localError := frame.GetLocalError() + if localError == nil { + t.Fatalf("git %s reply = %#v, want local_error", action, frame) + } + if !strings.Contains(localError.GetMessage(), "web git is disabled") { + t.Fatalf("git %s error = %q, want web git disabled message", action, localError.GetMessage()) + } + } +} + +func TestV2GitAllowsReadRequestsWhenDisabled(t *testing.T) { + t.Parallel() + + _, agentSession, conn, cleanup := newV2GitBrowserTest(t, false) + defer cleanup() + + sendGitAgentRequest(t, conn, "git-status-1", "status") + + outbound := readOutboundEnvelope(t, agentSession) + if outbound.GetGitRequest().GetAction() != "status" { + t.Fatalf("outbound = %#v, want forwarded git status request", outbound) + } +} + +func TestV2GitAllowsWriteRequestsWhenEnabled(t *testing.T) { + t.Parallel() + + _, agentSession, conn, cleanup := newV2GitBrowserTest(t, true) + defer cleanup() + + sendGitAgentRequest(t, conn, "git-stage-1", "stage") + + outbound := readOutboundEnvelope(t, agentSession) + if outbound.GetGitRequest().GetAction() != "stage" { + t.Fatalf("outbound = %#v, want forwarded git stage request", outbound) + } +} diff --git a/crates/agent-gateway/test/websocket/v2_helpers_test.go b/crates/agent-gateway/test/websocket/v2_helpers_test.go index f6dd08047..07d03b124 100644 --- a/crates/agent-gateway/test/websocket/v2_helpers_test.go +++ b/crates/agent-gateway/test/websocket/v2_helpers_test.go @@ -1,7 +1,7 @@ package websocket_test -// v2(WebSocket+Protobuf)二进制帧测试 harness,与 v1 JSON harness 并列:起真实 httptest -// 服务器、以子协议拨号、按 proto 帧收发。v1 harness 在 v1 删除前保持不动。 +// v2(WebSocket+Protobuf)二进制帧测试 harness:起真实 httptest 服务器、以子协议拨号、 +// 按 proto 帧收发。 import ( "net/http" @@ -14,6 +14,7 @@ import ( "google.golang.org/protobuf/proto" "github.com/liveagent/agent-gateway/internal/config" + gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" gatewayv2 "github.com/liveagent/agent-gateway/internal/proto/v2" "github.com/liveagent/agent-gateway/internal/protocol/pbws" "github.com/liveagent/agent-gateway/internal/session" @@ -153,3 +154,53 @@ func newV2BrowserTest(t *testing.T) (*session.Manager, *session.AgentSession, *w helloV2(t, conn, "ws-token") return sm, agentSession, conn, cleanup } + +// readOutboundEnvelope 取出网关发往桌面端的下一条信封并 Ack。 +func readOutboundEnvelope(t *testing.T, agentSession *session.AgentSession) *gatewayv1.GatewayEnvelope { + t.Helper() + select { + case outbound := <-agentSession.Outbound(): + outbound.Ack(nil) + return outbound.GatewayEnvelope + case <-time.After(time.Second): + t.Fatalf("timed out waiting for gateway request to reach agent") + return nil + } +} + +// answerChatRuntimeProbe 以假桌面端身份应答 chat.prepare 的关联 Ping 探测。 +func answerChatRuntimeProbe( + t *testing.T, + sm *session.Manager, + agentSession *session.AgentSession, +) string { + t.Helper() + envelope := readOutboundEnvelope(t, agentSession) + requestID := envelope.GetRequestId() + if !strings.HasPrefix(requestID, "chat-runtime-wake-") || envelope.GetPing() == nil { + t.Fatalf("chat runtime probe = %#v, want chat-runtime-wake-* Ping", envelope) + } + sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ + RequestId: requestID, + Timestamp: time.Now().Unix(), + Payload: &gatewayv1.AgentEnvelope_Pong{ + Pong: &gatewayv1.PongResponse{Timestamp: envelope.GetPing().GetTimestamp()}, + }, + }) + return requestID +} + +// dispatchStarted 以假桌面端身份上报 run 的 started 控制事件。 +func dispatchStarted(sm *session.Manager, runID string, conversationID string) { + sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ + RequestId: runID, + Payload: &gatewayv1.AgentEnvelope_ChatControl{ + ChatControl: &gatewayv1.ChatControlEvent{ + RequestId: runID, + ConversationId: conversationID, + Type: "started", + State: "running", + }, + }, + }) +} diff --git a/crates/agent-gateway/test/websocket/websocket_helpers_test.go b/crates/agent-gateway/test/websocket/websocket_helpers_test.go deleted file mode 100644 index 047a86028..000000000 --- a/crates/agent-gateway/test/websocket/websocket_helpers_test.go +++ /dev/null @@ -1,130 +0,0 @@ -package websocket_test - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/gorilla/websocket" - gatewayv1 "github.com/liveagent/agent-gateway/internal/proto/v1" - "github.com/liveagent/agent-gateway/internal/session" -) - -type wsEnvelope struct { - ID string `json:"id,omitempty"` - Type string `json:"type"` - Payload json.RawMessage `json:"payload,omitempty"` - Error string `json:"error,omitempty"` -} - -func dialGatewayWebSocket(t *testing.T, handler http.Handler) (*websocket.Conn, func()) { - t.Helper() - ts := httptest.NewServer(handler) - wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") - conn, _, err := websocket.DefaultDialer.Dial(wsURL, http.Header{ - "Origin": []string{ts.URL}, - }) - if err != nil { - ts.Close() - t.Fatalf("dial websocket: %v", err) - } - return conn, func() { - _ = conn.Close() - ts.Close() - } -} - -func sendEnvelope(t *testing.T, conn *websocket.Conn, id string, typ string, payload any) { - t.Helper() - env := map[string]any{ - "id": id, - "type": typ, - } - if payload != nil { - env["payload"] = payload - } - if err := conn.SetWriteDeadline(time.Now().Add(time.Second)); err != nil { - t.Fatalf("set websocket write deadline: %v", err) - } - if err := conn.WriteJSON(env); err != nil { - t.Fatalf("send %s: %v", typ, err) - } -} - -func receiveEnvelope(t *testing.T, conn *websocket.Conn) wsEnvelope { - t.Helper() - for { - if err := conn.SetReadDeadline(time.Now().Add(time.Second)); err != nil { - t.Fatalf("set websocket read deadline: %v", err) - } - var env wsEnvelope - if err := conn.ReadJSON(&env); err != nil { - t.Fatalf("receive websocket envelope: %v", err) - } - // tunnel.state, process.state and status.event are broadcast on auth - // and on unrelated state changes, and ping is periodic keep-alive; - // tests assert on the envelopes they explicitly provoke. - if env.Type == "tunnel.state" || env.Type == "process.state" || - env.Type == "status.event" || env.Type == "ping" { - continue - } - return env - } -} - -func receiveEnvelopeWithID(t *testing.T, conn *websocket.Conn, id string) wsEnvelope { - t.Helper() - for attempt := 0; attempt < 4; attempt += 1 { - env := receiveEnvelope(t, conn) - if env.ID == id { - return env - } - } - t.Fatalf("timed out waiting for websocket envelope id %q", id) - return wsEnvelope{} -} - -func authWebSocket(t *testing.T, conn *websocket.Conn, token string) { - t.Helper() - sendEnvelope(t, conn, "auth-1", "auth", map[string]any{"token": token}) - env := receiveEnvelope(t, conn) - if env.ID != "auth-1" || env.Type != "response" { - t.Fatalf("auth envelope = %#v, want response for auth-1", env) - } -} - -func readOutboundEnvelope(t *testing.T, agentSession *session.AgentSession) *gatewayv1.GatewayEnvelope { - t.Helper() - select { - case outbound := <-agentSession.Outbound(): - outbound.Ack(nil) - return outbound.GatewayEnvelope - case <-time.After(time.Second): - t.Fatalf("timed out waiting for gateway request to reach agent") - return nil - } -} - -func answerChatRuntimeProbe( - t *testing.T, - sm *session.Manager, - agentSession *session.AgentSession, -) string { - t.Helper() - envelope := readOutboundEnvelope(t, agentSession) - requestID := envelope.GetRequestId() - if !strings.HasPrefix(requestID, "chat-runtime-wake-") || envelope.GetPing() == nil { - t.Fatalf("chat runtime probe = %#v, want chat-runtime-wake-* Ping", envelope) - } - sm.DispatchFromAgent(&gatewayv1.AgentEnvelope{ - RequestId: requestID, - Timestamp: time.Now().Unix(), - Payload: &gatewayv1.AgentEnvelope_Pong{ - Pong: &gatewayv1.PongResponse{Timestamp: envelope.GetPing().GetTimestamp()}, - }, - }) - return requestID -} diff --git a/crates/agent-gateway/web/src/i18n/config.ts b/crates/agent-gateway/web/src/i18n/config.ts index fc3bc9f34..a281239f3 100644 --- a/crates/agent-gateway/web/src/i18n/config.ts +++ b/crates/agent-gateway/web/src/i18n/config.ts @@ -1460,12 +1460,8 @@ export const translations: Record> = { "settings.remoteDisable": "关闭远程访问", "settings.remoteGatewayConnection": "Gateway 连接", "settings.remoteGatewayUrl": "Gateway 地址", - "settings.remoteGatewayUrlHint": "云端 Gateway 的 HTTPS 地址,用于 WebUI 访问和 gRPC 连接", - "settings.remoteGrpcPort": "gRPC 端口", - "settings.remoteGrpcPortHint": "Gateway 上 gRPC 服务的监听端口,默认 50051", - "settings.remoteGrpcEndpoint": "gRPC Endpoint", - "settings.remoteGrpcEndpointHint": - "可选。Railway TCP Proxy 等场景可填写独立 gRPC 地址,留空则使用 Gateway 地址加 gRPC 端口。", + "settings.remoteGatewayUrlHint": + "云端 Gateway 的 HTTPS 地址,用于 WebUI 访问与桌面端 v2 WebSocket 连接", "settings.remoteAuth": "身份认证", "settings.remoteToken": "访问令牌", "settings.remoteTokenPlaceholder": "输入与 Gateway 配置一致的 Token", @@ -1493,7 +1489,7 @@ export const translations: Record> = { "settings.remoteHeartbeatHint": "与 Gateway 连接的保活心跳间隔(生效范围 10-60 秒),用于维持连接和检测在线状态", "settings.remoteInfoBanner": - "启用后,本地 LiveAgent 将通过 gRPC 双向流连接云端 Gateway。你可以在浏览器中通过 WebUI 远程发送 Chat 消息、管理 Cron 任务和查看历史记录。所有工具执行仍在本地完成,远程端仅转发指令和结果。", + "启用后,本地 LiveAgent 将通过 WebSocket(v2 协议)连接云端 Gateway。你可以在浏览器中通过 WebUI 远程发送 Chat 消息、管理 Cron 任务和查看历史记录。所有工具执行仍在本地完成,远程端仅转发指令和结果。", /* ── MCP Hub ── */ "mcpHub.title": "MCP Servers", @@ -3329,12 +3325,7 @@ export const translations: Record> = { "settings.remoteGatewayConnection": "Gateway Connection", "settings.remoteGatewayUrl": "Gateway URL", "settings.remoteGatewayUrlHint": - "HTTPS address of the cloud Gateway for WebUI access and gRPC connection", - "settings.remoteGrpcPort": "gRPC Port", - "settings.remoteGrpcPortHint": "The gRPC service port on the Gateway, default 50051", - "settings.remoteGrpcEndpoint": "gRPC Endpoint", - "settings.remoteGrpcEndpointHint": - "Optional. Use a separate gRPC address for Railway TCP Proxy or similar hosts. Leave empty to use the Gateway URL plus gRPC port.", + "HTTPS address of the cloud Gateway for WebUI access and the desktop v2 WebSocket link", "settings.remoteAuth": "Authentication", "settings.remoteToken": "Access Token", "settings.remoteTokenPlaceholder": "Enter the token matching Gateway config", @@ -3364,7 +3355,7 @@ export const translations: Record> = { "settings.remoteHeartbeatHint": "Keepalive heartbeat interval for the Gateway connection (effective range 10-60 seconds), used to maintain the connection and detect online status", "settings.remoteInfoBanner": - "When enabled, the local LiveAgent connects to the cloud Gateway via gRPC bidirectional streaming. You can remotely send Chat messages, manage Cron tasks, and view history through the WebUI in your browser. All tool execution remains local — the remote end only relays commands and results.", + "When enabled, the local LiveAgent connects to the cloud Gateway over WebSocket (protocol v2). You can remotely send Chat messages, manage Cron tasks, and view history through the WebUI in your browser. All tool execution remains local — the remote end only relays commands and results.", /* ── MCP Hub ── */ "mcpHub.title": "MCP Servers", diff --git a/crates/agent-gateway/web/src/lib/proto/gen/proto/v1/gateway_pb.ts b/crates/agent-gateway/web/src/lib/proto/gen/proto/v1/gateway_pb.ts index f7a143ebe..11f1840c4 100644 --- a/crates/agent-gateway/web/src/lib/proto/gen/proto/v1/gateway_pb.ts +++ b/crates/agent-gateway/web/src/lib/proto/gen/proto/v1/gateway_pb.ts @@ -2,15 +2,15 @@ // @generated from file proto/v1/gateway.proto (package liveagent.gateway.v1, syntax proto3) /* eslint-disable */ -import type { GenEnum, GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; -import { enumDesc, fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { GenEnum, GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; import type { Message } from "@bufbuild/protobuf"; /** * Describes the file proto/v1/gateway.proto. */ export const file_proto_v1_gateway: GenFile = /*@__PURE__*/ - fileDesc("ChZwcm90by92MS9nYXRld2F5LnByb3RvEhRsaXZlYWdlbnQuZ2F0ZXdheS52MSJFCgtBdXRoUmVxdWVzdBINCgV0b2tlbhgBIAEoCRIQCghhZ2VudF9pZBgCIAEoCRIVCg1hZ2VudF92ZXJzaW9uGAMgASgJIkQKDEF1dGhSZXNwb25zZRIPCgdzdWNjZXNzGAEgASgIEg8KB21lc3NhZ2UYAiABKAkSEgoKc2Vzc2lvbl9pZBgDIAEoCSLpGQoPR2F0ZXdheUVudmVsb3BlEhIKCnJlcXVlc3RfaWQYASABKAkSEQoJdGltZXN0YW1wGAIgASgDEkAKDGNoYXRfY29tbWFuZBgKIAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYxLkNoYXRDb21tYW5kUmVxdWVzdEgAEj4KC2Nyb25fbWFuYWdlGBQgASgLMicubGl2ZWFnZW50LmdhdGV3YXkudjEuQ3Jvbk1hbmFnZVJlcXVlc3RIABJACgxoaXN0b3J5X2xpc3QYHiABKAsyKC5saXZlYWdlbnQuZ2F0ZXdheS52MS5IaXN0b3J5TGlzdFJlcXVlc3RIABI+CgtoaXN0b3J5X2dldBgfIAEoCzInLmxpdmVhZ2VudC5nYXRld2F5LnYxLkhpc3RvcnlHZXRSZXF1ZXN0SAASRAoOaGlzdG9yeV9yZW5hbWUYICABKAsyKi5saXZlYWdlbnQuZ2F0ZXdheS52MS5IaXN0b3J5UmVuYW1lUmVxdWVzdEgAEkQKDmhpc3RvcnlfZGVsZXRlGCEgASgLMioubGl2ZWFnZW50LmdhdGV3YXkudjEuSGlzdG9yeURlbGV0ZVJlcXVlc3RIABJECg5oaXN0b3J5X3ByZWZpeBgiIAEoCzIqLmxpdmVhZ2VudC5nYXRld2F5LnYxLkhpc3RvcnlQcmVmaXhSZXF1ZXN0SAASPgoLaGlzdG9yeV9waW4YIyABKAsyJy5saXZlYWdlbnQuZ2F0ZXdheS52MS5IaXN0b3J5UGluUmVxdWVzdEgAEkkKEWhpc3Rvcnlfc2hhcmVfZ2V0GCQgASgLMiwubGl2ZWFnZW50LmdhdGV3YXkudjEuSGlzdG9yeVNoYXJlR2V0UmVxdWVzdEgAEkkKEWhpc3Rvcnlfc2hhcmVfc2V0GCUgASgLMiwubGl2ZWFnZW50LmdhdGV3YXkudjEuSGlzdG9yeVNoYXJlU2V0UmVxdWVzdEgAElEKFWhpc3Rvcnlfc2hhcmVfcmVzb2x2ZRgmIAEoCzIwLmxpdmVhZ2VudC5nYXRld2F5LnYxLkhpc3RvcnlTaGFyZVJlc29sdmVSZXF1ZXN0SAASSAoQaGlzdG9yeV93b3JrZGlycxgnIAEoCzIsLmxpdmVhZ2VudC5nYXRld2F5LnYxLkhpc3RvcnlXb3JrZGlyc1JlcXVlc3RIABJCCg1wcm92aWRlcl9saXN0GCggASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjEuUHJvdmlkZXJMaXN0UmVxdWVzdEgAEkAKDHNldHRpbmdzX2dldBgpIAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYxLlNldHRpbmdzR2V0UmVxdWVzdEgAEkYKD3NldHRpbmdzX3VwZGF0ZRgqIAEoCzIrLmxpdmVhZ2VudC5nYXRld2F5LnYxLlNldHRpbmdzVXBkYXRlUmVxdWVzdEgAEkcKEHNraWxsX2ZpbGVzX2xpc3QYKyABKAsyKy5saXZlYWdlbnQuZ2F0ZXdheS52MS5Ta2lsbEZpbGVzTGlzdFJlcXVlc3RIABJNChNza2lsbF9tZXRhZGF0YV9yZWFkGCwgASgLMi4ubGl2ZWFnZW50LmdhdGV3YXkudjEuU2tpbGxNZXRhZGF0YVJlYWRSZXF1ZXN0SAASRQoPc2tpbGxfdGV4dF9yZWFkGC0gASgLMioubGl2ZWFnZW50LmdhdGV3YXkudjEuU2tpbGxUZXh0UmVhZFJlcXVlc3RIABJJChFmaWxlX21lbnRpb25fbGlzdBguIAEoCzIsLmxpdmVhZ2VudC5nYXRld2F5LnYxLkZpbGVNZW50aW9uTGlzdFJlcXVlc3RIABJRChV1cGxvYWRfcmVhZGFibGVfZmlsZXMYLyABKAsyMC5saXZlYWdlbnQuZ2F0ZXdheS52MS5VcGxvYWRSZWFkYWJsZUZpbGVzUmVxdWVzdEgAEjgKCGZzX3Jvb3RzGDAgASgLMiQubGl2ZWFnZW50LmdhdGV3YXkudjEuRnNSb290c1JlcXVlc3RIABI/Cgxmc19saXN0X2RpcnMYMSABKAsyJy5saXZlYWdlbnQuZ2F0ZXdheS52MS5Gc0xpc3REaXJzUmVxdWVzdEgAEjEKBHBpbmcYMiABKAsyIS5saXZlYWdlbnQuZ2F0ZXdheS52MS5QaW5nUmVxdWVzdEgAElMKFnVwbG9hZGVkX2ltYWdlX3ByZXZpZXcYMyABKAsyMS5saXZlYWdlbnQuZ2F0ZXdheS52MS5VcGxvYWRlZEltYWdlUHJldmlld1JlcXVlc3RIABJCCg1tZW1vcnlfbWFuYWdlGDQgASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjEuTWVtb3J5TWFuYWdlUmVxdWVzdEgAEkAKDHNraWxsX21hbmFnZRg1IAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYxLlNraWxsTWFuYWdlUmVxdWVzdEgAElYKGGZzX2NyZWF0ZV9wcm9qZWN0X2ZvbGRlchg2IAEoCzIyLmxpdmVhZ2VudC5nYXRld2F5LnYxLkZzQ3JlYXRlUHJvamVjdEZvbGRlclJlcXVlc3RIABJBChB0ZXJtaW5hbF9yZXF1ZXN0GDcgASgLMiUubGl2ZWFnZW50LmdhdGV3YXkudjEuVGVybWluYWxSZXF1ZXN0SAASNgoHZnNfbGlzdBg4IAEoCzIjLmxpdmVhZ2VudC5nYXRld2F5LnYxLkZzTGlzdFJlcXVlc3RIABJBCg1mc193cml0ZV90ZXh0GDkgASgLMigubGl2ZWFnZW50LmdhdGV3YXkudjEuRnNXcml0ZVRleHRSZXF1ZXN0SAASQQoNZnNfY3JlYXRlX2Rpchg6IAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYxLkZzQ3JlYXRlRGlyUmVxdWVzdEgAEjoKCWZzX3JlbmFtZRg7IAEoCzIlLmxpdmVhZ2VudC5nYXRld2F5LnYxLkZzUmVuYW1lUmVxdWVzdEgAEjoKCWZzX2RlbGV0ZRg8IAEoCzIlLmxpdmVhZ2VudC5nYXRld2F5LnYxLkZzRGVsZXRlUmVxdWVzdEgAEjcKC2dpdF9yZXF1ZXN0GD0gASgLMiAubGl2ZWFnZW50LmdhdGV3YXkudjEuR2l0UmVxdWVzdEgAElAKFWZzX3JlYWRfZWRpdGFibGVfdGV4dBg+IAEoCzIvLmxpdmVhZ2VudC5nYXRld2F5LnYxLkZzUmVhZEVkaXRhYmxlVGV4dFJlcXVlc3RIABJUChdmc19yZWFkX3dvcmtzcGFjZV9pbWFnZRg/IAEoCzIxLmxpdmVhZ2VudC5nYXRld2F5LnYxLkZzUmVhZFdvcmtzcGFjZUltYWdlUmVxdWVzdEgAEjkKDHNmdHBfcmVxdWVzdBhAIAEoCzIhLmxpdmVhZ2VudC5nYXRld2F5LnYxLlNmdHBSZXF1ZXN0SAASRgoPcHJvdmlkZXJfbW9kZWxzGEEgASgLMisubGl2ZWFnZW50LmdhdGV3YXkudjEuUHJvdmlkZXJNb2RlbHNSZXF1ZXN0SAASXwodc2V0dGluZ3NfcmVzZXRfc3NoX2tub3duX2hvc3QYSCABKAsyNi5saXZlYWdlbnQuZ2F0ZXdheS52MS5TZXR0aW5nc1Jlc2V0U3NoS25vd25Ib3N0UmVxdWVzdEgAEjwKCmNoYXRfcXVldWUYSSABKAsyJi5saXZlYWdlbnQuZ2F0ZXdheS52MS5DaGF0UXVldWVSZXF1ZXN0SAASQQoMdHVubmVsX3N0YXRlGFAgASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjEuVHVubmVsU3RhdGVTbmFwc2hvdEgAEj8KD3R1bm5lbF9tdXRhdGlvbhhRIAEoCzIkLmxpdmVhZ2VudC5nYXRld2F5LnYxLlR1bm5lbE11dGF0aW9uSAASOQoMdHVubmVsX2ZyYW1lGFIgASgLMiEubGl2ZWFnZW50LmdhdGV3YXkudjEuVHVubmVsRnJhbWVIABJGCg93b3Jrc3BhY2Vfd2F0Y2gYWiABKAsyKy5saXZlYWdlbnQuZ2F0ZXdheS52MS5Xb3Jrc3BhY2VXYXRjaFJlcXVlc3RIABJOChdtYW5hZ2VkX3Byb2Nlc3NfcmVxdWVzdBhbIAEoCzIrLmxpdmVhZ2VudC5nYXRld2F5LnYxLk1hbmFnZWRQcm9jZXNzUmVxdWVzdEgAEkQKDmhpc3RvcnlfYnJhbmNoGFwgASgLMioubGl2ZWFnZW50LmdhdGV3YXkudjEuSGlzdG9yeUJyYW5jaFJlcXVlc3RIAEIJCgdwYXlsb2FkSgQIQxBESgQIRBBFSgQIRRBGSgQIShBLIqkhCg1BZ2VudEVudmVsb3BlEhIKCnJlcXVlc3RfaWQYASABKAkSEQoJdGltZXN0YW1wGAIgASgDEjUKCmNoYXRfZXZlbnQYCiABKAsyHy5saXZlYWdlbnQuZ2F0ZXdheS52MS5DaGF0RXZlbnRIABJEChBjcm9uX21hbmFnZV9yZXNwGBQgASgLMigubGl2ZWFnZW50LmdhdGV3YXkudjEuQ3Jvbk1hbmFnZVJlc3BvbnNlSAASRgoRaGlzdG9yeV9saXN0X3Jlc3AYHiABKAsyKS5saXZlYWdlbnQuZ2F0ZXdheS52MS5IaXN0b3J5TGlzdFJlc3BvbnNlSAASRAoQaGlzdG9yeV9nZXRfcmVzcBgfIAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYxLkhpc3RvcnlHZXRSZXNwb25zZUgAEkoKE2hpc3RvcnlfcmVuYW1lX3Jlc3AYICABKAsyKy5saXZlYWdlbnQuZ2F0ZXdheS52MS5IaXN0b3J5UmVuYW1lUmVzcG9uc2VIABJKChNoaXN0b3J5X2RlbGV0ZV9yZXNwGCEgASgLMisubGl2ZWFnZW50LmdhdGV3YXkudjEuSGlzdG9yeURlbGV0ZVJlc3BvbnNlSAASPgoMaGlzdG9yeV9zeW5jGCIgASgLMiYubGl2ZWFnZW50LmdhdGV3YXkudjEuSGlzdG9yeVN5bmNFdmVudEgAEkoKE2hpc3RvcnlfcHJlZml4X3Jlc3AYIyABKAsyKy5saXZlYWdlbnQuZ2F0ZXdheS52MS5IaXN0b3J5UHJlZml4UmVzcG9uc2VIABJEChBoaXN0b3J5X3Bpbl9yZXNwGCQgASgLMigubGl2ZWFnZW50LmdhdGV3YXkudjEuSGlzdG9yeVBpblJlc3BvbnNlSAASTwoWaGlzdG9yeV9zaGFyZV9nZXRfcmVzcBglIAEoCzItLmxpdmVhZ2VudC5nYXRld2F5LnYxLkhpc3RvcnlTaGFyZUdldFJlc3BvbnNlSAASTwoWaGlzdG9yeV9zaGFyZV9zZXRfcmVzcBgmIAEoCzItLmxpdmVhZ2VudC5nYXRld2F5LnYxLkhpc3RvcnlTaGFyZVNldFJlc3BvbnNlSAASVwoaaGlzdG9yeV9zaGFyZV9yZXNvbHZlX3Jlc3AYJyABKAsyMS5saXZlYWdlbnQuZ2F0ZXdheS52MS5IaXN0b3J5U2hhcmVSZXNvbHZlUmVzcG9uc2VIABJOChVoaXN0b3J5X3dvcmtkaXJzX3Jlc3AYOCABKAsyLS5saXZlYWdlbnQuZ2F0ZXdheS52MS5IaXN0b3J5V29ya2RpcnNSZXNwb25zZUgAEkgKEnByb3ZpZGVyX2xpc3RfcmVzcBgoIAEoCzIqLmxpdmVhZ2VudC5nYXRld2F5LnYxLlByb3ZpZGVyTGlzdFJlc3BvbnNlSAASRgoRc2V0dGluZ3NfZ2V0X3Jlc3AYKSABKAsyKS5saXZlYWdlbnQuZ2F0ZXdheS52MS5TZXR0aW5nc0dldFJlc3BvbnNlSAASTAoUc2V0dGluZ3NfdXBkYXRlX3Jlc3AYKiABKAsyLC5saXZlYWdlbnQuZ2F0ZXdheS52MS5TZXR0aW5nc1VwZGF0ZVJlc3BvbnNlSAASQAoNc2V0dGluZ3Nfc3luYxgrIAEoCzInLmxpdmVhZ2VudC5nYXRld2F5LnYxLlNldHRpbmdzU3luY0V2ZW50SAASTQoVc2tpbGxfZmlsZXNfbGlzdF9yZXNwGCwgASgLMiwubGl2ZWFnZW50LmdhdGV3YXkudjEuU2tpbGxGaWxlc0xpc3RSZXNwb25zZUgAElMKGHNraWxsX21ldGFkYXRhX3JlYWRfcmVzcBgtIAEoCzIvLmxpdmVhZ2VudC5nYXRld2F5LnYxLlNraWxsTWV0YWRhdGFSZWFkUmVzcG9uc2VIABJLChRza2lsbF90ZXh0X3JlYWRfcmVzcBguIAEoCzIrLmxpdmVhZ2VudC5nYXRld2F5LnYxLlNraWxsVGV4dFJlYWRSZXNwb25zZUgAEk8KFmZpbGVfbWVudGlvbl9saXN0X3Jlc3AYLyABKAsyLS5saXZlYWdlbnQuZ2F0ZXdheS52MS5GaWxlTWVudGlvbkxpc3RSZXNwb25zZUgAElcKGnVwbG9hZF9yZWFkYWJsZV9maWxlc19yZXNwGDAgASgLMjEubGl2ZWFnZW50LmdhdGV3YXkudjEuVXBsb2FkUmVhZGFibGVGaWxlc1Jlc3BvbnNlSAASPgoNZnNfcm9vdHNfcmVzcBgxIAEoCzIlLmxpdmVhZ2VudC5nYXRld2F5LnYxLkZzUm9vdHNSZXNwb25zZUgAEjIKBHBvbmcYMiABKAsyIi5saXZlYWdlbnQuZ2F0ZXdheS52MS5Qb25nUmVzcG9uc2VIABJFChFmc19saXN0X2RpcnNfcmVzcBgzIAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYxLkZzTGlzdERpcnNSZXNwb25zZUgAElkKG3VwbG9hZGVkX2ltYWdlX3ByZXZpZXdfcmVzcBg0IAEoCzIyLmxpdmVhZ2VudC5nYXRld2F5LnYxLlVwbG9hZGVkSW1hZ2VQcmV2aWV3UmVzcG9uc2VIABJIChJtZW1vcnlfbWFuYWdlX3Jlc3AYNSABKAsyKi5saXZlYWdlbnQuZ2F0ZXdheS52MS5NZW1vcnlNYW5hZ2VSZXNwb25zZUgAEkYKEXNraWxsX21hbmFnZV9yZXNwGDYgASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjEuU2tpbGxNYW5hZ2VSZXNwb25zZUgAElwKHWZzX2NyZWF0ZV9wcm9qZWN0X2ZvbGRlcl9yZXNwGDcgASgLMjMubGl2ZWFnZW50LmdhdGV3YXkudjEuRnNDcmVhdGVQcm9qZWN0Rm9sZGVyUmVzcG9uc2VIABJDChF0ZXJtaW5hbF9yZXNwb25zZRg5IAEoCzImLmxpdmVhZ2VudC5nYXRld2F5LnYxLlRlcm1pbmFsUmVzcG9uc2VIABI9Cg50ZXJtaW5hbF9ldmVudBg6IAEoCzIjLmxpdmVhZ2VudC5nYXRld2F5LnYxLlRlcm1pbmFsRXZlbnRIABI8Cgxmc19saXN0X3Jlc3AYOyABKAsyJC5saXZlYWdlbnQuZ2F0ZXdheS52MS5Gc0xpc3RSZXNwb25zZUgAEkcKEmZzX3dyaXRlX3RleHRfcmVzcBg8IAEoCzIpLmxpdmVhZ2VudC5nYXRld2F5LnYxLkZzV3JpdGVUZXh0UmVzcG9uc2VIABJHChJmc19jcmVhdGVfZGlyX3Jlc3AYPSABKAsyKS5saXZlYWdlbnQuZ2F0ZXdheS52MS5Gc0NyZWF0ZURpclJlc3BvbnNlSAASQAoOZnNfcmVuYW1lX3Jlc3AYPiABKAsyJi5saXZlYWdlbnQuZ2F0ZXdheS52MS5Gc1JlbmFtZVJlc3BvbnNlSAASQAoOZnNfZGVsZXRlX3Jlc3AYPyABKAsyJi5saXZlYWdlbnQuZ2F0ZXdheS52MS5Gc0RlbGV0ZVJlc3BvbnNlSAASOQoMZ2l0X3Jlc3BvbnNlGEAgASgLMiEubGl2ZWFnZW50LmdhdGV3YXkudjEuR2l0UmVzcG9uc2VIABJWChpmc19yZWFkX2VkaXRhYmxlX3RleHRfcmVzcBhBIAEoCzIwLmxpdmVhZ2VudC5nYXRld2F5LnYxLkZzUmVhZEVkaXRhYmxlVGV4dFJlc3BvbnNlSAASWgocZnNfcmVhZF93b3Jrc3BhY2VfaW1hZ2VfcmVzcBhCIAEoCzIyLmxpdmVhZ2VudC5nYXRld2F5LnYxLkZzUmVhZFdvcmtzcGFjZUltYWdlUmVzcG9uc2VIABI7Cg1zZnRwX3Jlc3BvbnNlGEkgASgLMiIubGl2ZWFnZW50LmdhdGV3YXkudjEuU2Z0cFJlc3BvbnNlSAASNQoKc2Z0cF9ldmVudBhKIAEoCzIfLmxpdmVhZ2VudC5nYXRld2F5LnYxLlNmdHBFdmVudEgAEkIKD2NoYXRfcXVldWVfcmVzcBhLIAEoCzInLmxpdmVhZ2VudC5nYXRld2F5LnYxLkNoYXRRdWV1ZVJlc3BvbnNlSAASQAoQY2hhdF9xdWV1ZV9ldmVudBhMIAEoCzIkLmxpdmVhZ2VudC5nYXRld2F5LnYxLkNoYXRRdWV1ZUV2ZW50SAASPgoMY2hhdF9jb250cm9sGEYgASgLMiYubGl2ZWFnZW50LmdhdGV3YXkudjEuQ2hhdENvbnRyb2xFdmVudEgAEkIKDnJ1bnRpbWVfc3RhdHVzGEcgASgLMigubGl2ZWFnZW50LmdhdGV3YXkudjEuUnVudGltZVN0YXR1c0V2ZW50SAASZQoic2V0dGluZ3NfcmVzZXRfc3NoX2tub3duX2hvc3RfcmVzcBhIIAEoCzI3LmxpdmVhZ2VudC5nYXRld2F5LnYxLlNldHRpbmdzUmVzZXRTc2hLbm93bkhvc3RSZXNwb25zZUgAEkoKFWNoYXRfcnVudGltZV9zbmFwc2hvdBhNIAEoCzIpLmxpdmVhZ2VudC5nYXRld2F5LnYxLkNoYXRSdW50aW1lU25hcHNob3RIABJMChRwcm92aWRlcl9tb2RlbHNfcmVzcBhPIAEoCzIsLmxpdmVhZ2VudC5nYXRld2F5LnYxLlByb3ZpZGVyTW9kZWxzUmVzcG9uc2VIABJCCg50dW5uZWxfZGVzaXJlZBhQIAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYxLlR1bm5lbERlc2lyZWRTdGF0ZUgAEkwKFnR1bm5lbF9tdXRhdGlvbl9yZXN1bHQYUSABKAsyKi5saXZlYWdlbnQuZ2F0ZXdheS52MS5UdW5uZWxNdXRhdGlvblJlc3VsdEgAEjkKDHR1bm5lbF9mcmFtZRhSIAEoCzIhLmxpdmVhZ2VudC5nYXRld2F5LnYxLlR1bm5lbEZyYW1lSAASRgoTdHVubmVsX3Byb2JlX3JlcG9ydBhTIAEoCzInLmxpdmVhZ2VudC5nYXRld2F5LnYxLlR1bm5lbFByb2JlUmVwb3J0SAASSgoSd29ya3NwYWNlX2FjdGl2aXR5GFogASgLMiwubGl2ZWFnZW50LmdhdGV3YXkudjEuV29ya3NwYWNlQWN0aXZpdHlFdmVudEgAElAKGG1hbmFnZWRfcHJvY2Vzc19yZXNwb25zZRhbIAEoCzIsLmxpdmVhZ2VudC5nYXRld2F5LnYxLk1hbmFnZWRQcm9jZXNzUmVzcG9uc2VIABJQChhtYW5hZ2VkX3Byb2Nlc3Nfc25hcHNob3QYXCABKAsyLC5saXZlYWdlbnQuZ2F0ZXdheS52MS5NYW5hZ2VkUHJvY2Vzc1NuYXBzaG90SAASSgoTaGlzdG9yeV9icmFuY2hfcmVzcBhdIAEoCzIrLmxpdmVhZ2VudC5nYXRld2F5LnYxLkhpc3RvcnlCcmFuY2hSZXNwb25zZUgAEjQKBWVycm9yGGMgASgLMiMubGl2ZWFnZW50LmdhdGV3YXkudjEuRXJyb3JSZXNwb25zZUgAQgkKB3BheWxvYWRKBAhDEERKBAhEEEVKBAhFEEZKBAhOEE8iVQoRQ2hhdFNlbGVjdGVkTW9kZWwSGgoSY3VzdG9tX3Byb3ZpZGVyX2lkGAEgASgJEg0KBW1vZGVsGAIgASgJEhUKDXByb3ZpZGVyX3R5cGUYAyABKAkiZQoTQ2hhdFJ1bnRpbWVDb250cm9scxIYChB0aGlua2luZ19lbmFibGVkGAEgASgIEiEKGW5hdGl2ZV93ZWJfc2VhcmNoX2VuYWJsZWQYAiABKAgSEQoJcmVhc29uaW5nGAMgASgJInUKEENoYXRVcGxvYWRlZEZpbGUSFQoNcmVsYXRpdmVfcGF0aBgBIAEoCRIVCg1hYnNvbHV0ZV9wYXRoGAIgASgJEhEKCWZpbGVfbmFtZRgDIAEoCRIMCgRraW5kGAQgASgJEhIKCnNpemVfYnl0ZXMYBSABKAMiSwoSVXBsb2FkUmVhZGFibGVGaWxlEhEKCWZpbGVfbmFtZRgBIAEoCRIRCgltaW1lX3R5cGUYAiABKAkSDwoHY29udGVudBgDIAEoDCJmChpVcGxvYWRSZWFkYWJsZUZpbGVzUmVxdWVzdBIPCgd3b3JrZGlyGAEgASgJEjcKBWZpbGVzGAIgAygLMigubGl2ZWFnZW50LmdhdGV3YXkudjEuVXBsb2FkUmVhZGFibGVGaWxlImUKG1VwbG9hZFJlYWRhYmxlRmlsZXNSZXNwb25zZRI1CgVmaWxlcxgBIAMoCzImLmxpdmVhZ2VudC5nYXRld2F5LnYxLkNoYXRVcGxvYWRlZEZpbGUSDwoHc2tpcHBlZBgCIAMoCSJFChtVcGxvYWRlZEltYWdlUHJldmlld1JlcXVlc3QSDwoHd29ya2RpchgBIAEoCRIVCg1hYnNvbHV0ZV9wYXRoGAIgASgJIj8KHFVwbG9hZGVkSW1hZ2VQcmV2aWV3UmVzcG9uc2USEQoJbWltZV90eXBlGAEgASgJEgwKBGRhdGEYAiABKAkiewoKVHVubmVsU3BlYxIKCgJpZBgBIAEoCRIRCglzbHVnX2hpbnQYAiABKAkSDAoEbmFtZRgDIAEoCRISCgp0YXJnZXRfdXJsGAQgASgJEhIKCmV4cGlyZXNfYXQYBSABKAMSGAoQcHJvamVjdF9wYXRoX2tleRgGIAEoCSJZChJUdW5uZWxEZXNpcmVkU3RhdGUSMQoHdHVubmVscxgBIAMoCzIgLmxpdmVhZ2VudC5nYXRld2F5LnYxLlR1bm5lbFNwZWMSEAoIcmV2aXNpb24YAiABKAQiZgoMVHVubmVsSGVhbHRoEg4KBnN0YXR1cxgBIAEoCRITCgtodHRwX3N0YXR1cxgCIAEoDRINCgVlcnJvchgDIAEoCRISCgpjaGVja2VkX2F0GAQgASgDEg4KBnJ0dF9tcxgFIAEoDSLwAQoMVHVubmVsU3RhdHVzEgoKAmlkGAEgASgJEgwKBHNsdWcYAiABKAkSDAoEbmFtZRgDIAEoCRISCgp0YXJnZXRfdXJsGAQgASgJEhMKC3B1YmxpY19wYXRoGAUgASgJEhIKCmNyZWF0ZWRfYXQYBiABKAMSEgoKZXhwaXJlc19hdBgHIAEoAxIaChJhY3RpdmVfY29ubmVjdGlvbnMYCCABKA0SGAoQcHJvamVjdF9wYXRoX2tleRgJIAEoCRIxCgVsb2NhbBgKIAEoCzIiLmxpdmVhZ2VudC5nYXRld2F5LnYxLlR1bm5lbEhlYWx0aCKlAQoTVHVubmVsU3RhdGVTbmFwc2hvdBIzCgd0dW5uZWxzGAEgAygLMiIubGl2ZWFnZW50LmdhdGV3YXkudjEuVHVubmVsU3RhdHVzEhAKCHJldmlzaW9uGAIgASgEEhQKDGFnZW50X29ubGluZRgDIAEoCBIxCgVyZWxheRgEIAEoCzIiLmxpdmVhZ2VudC5nYXRld2F5LnYxLlR1bm5lbEhlYWx0aCKZAQoOVHVubmVsTXV0YXRpb24SDgoGYWN0aW9uGAEgASgJEhEKCXR1bm5lbF9pZBgCIAEoCRISCgp0YXJnZXRfdXJsGAMgASgJEgwKBG5hbWUYBCABKAkSGAoLdHRsX3NlY29uZHMYBSABKA1IAIgBARIYChBwcm9qZWN0X3BhdGhfa2V5GAYgASgJQg4KDF90dGxfc2Vjb25kcyJUChRUdW5uZWxNdXRhdGlvblJlc3VsdBIRCgl0dW5uZWxfaWQYASABKAkSEgoKZXJyb3JfY29kZRgCIAEoCRIVCg1lcnJvcl9tZXNzYWdlGAMgASgJIlkKEVR1bm5lbFByb2JlUmVzdWx0EhEKCXR1bm5lbF9pZBgBIAEoCRIxCgVsb2NhbBgCIAEoCzIiLmxpdmVhZ2VudC5nYXRld2F5LnYxLlR1bm5lbEhlYWx0aCJNChFUdW5uZWxQcm9iZVJlcG9ydBI4CgdyZXN1bHRzGAEgAygLMicubGl2ZWFnZW50LmdhdGV3YXkudjEuVHVubmVsUHJvYmVSZXN1bHQiKwoMVHVubmVsSGVhZGVyEgwKBG5hbWUYASABKAkSDQoFdmFsdWUYAiABKAki9QIKC1R1bm5lbEZyYW1lEhEKCXN0cmVhbV9pZBgBIAEoCRIzCgRraW5kGAIgASgOMiUubGl2ZWFnZW50LmdhdGV3YXkudjEuVHVubmVsRnJhbWVLaW5kEhIKCnRhcmdldF91cmwYAyABKAkSDgoGbWV0aG9kGAQgASgJEgwKBHBhdGgYBSABKAkSMwoHaGVhZGVycxgGIAMoCzIiLmxpdmVhZ2VudC5nYXRld2F5LnYxLlR1bm5lbEhlYWRlchIOCgZzdGF0dXMYByABKA0SDAoEYm9keRgIIAEoDBINCgVlcnJvchgJIAEoCRJCCg93c19tZXNzYWdlX3R5cGUYCiABKA4yKS5saXZlYWdlbnQuZ2F0ZXdheS52MS5UdW5uZWxXc01lc3NhZ2VUeXBlEhYKDndzX3N1YnByb3RvY29sGAsgASgJEhUKDXdzX2Nsb3NlX2NvZGUYDCABKA0SFwoPd3NfY2xvc2VfcmVhc29uGA0gASgJIikKFVdvcmtzcGFjZVdhdGNoUmVxdWVzdBIQCgh3b3JrZGlycxgBIAMoCSJ+ChZXb3Jrc3BhY2VBY3Rpdml0eUV2ZW50Eg8KB3dvcmtkaXIYASABKAkSEAoIcmV2aXNpb24YAiABKAQSCgoCZnMYAyABKAgSCwoDZ2l0GAQgASgIEhUKDWNoYW5nZWRfcGF0aHMYBSADKAkSEQoJdHJ1bmNhdGVkGAYgASgIIpYCChRNYW5hZ2VkUHJvY2Vzc1JlY29yZBIKCgJpZBgBIAEoCRINCgVsYWJlbBgCIAEoCRIPCgdjb21tYW5kGAMgASgJEgsKA2N3ZBgEIAEoCRINCgVzaGVsbBgFIAEoCRILCgNwaWQYBiABKA0SEAoIbG9nX3BhdGgYByABKAkSEgoKc3RhcnRlZF9hdBgIIAEoAxIYCgtmaW5pc2hlZF9hdBgJIAEoA0gAiAEBEhYKCWV4aXRfY29kZRgKIAEoBUgBiAEBEg8KB3J1bm5pbmcYCyABKAgSEAoIaXNvbGF0ZWQYDCABKAgSEAoIcmVzdG9yZWQYDSABKAhCDgoMX2ZpbmlzaGVkX2F0QgwKCl9leGl0X2NvZGUiaQoWTWFuYWdlZFByb2Nlc3NTbmFwc2hvdBI9Cglwcm9jZXNzZXMYASADKAsyKi5saXZlYWdlbnQuZ2F0ZXdheS52MS5NYW5hZ2VkUHJvY2Vzc1JlY29yZBIQCghyZXZpc2lvbhgCIAEoBCJOChVNYW5hZ2VkUHJvY2Vzc1JlcXVlc3QSDgoGYWN0aW9uGAEgASgJEhIKCnByb2Nlc3NfaWQYAiABKAkSEQoJbWF4X2J5dGVzGAMgASgNIrcBChZNYW5hZ2VkUHJvY2Vzc1Jlc3BvbnNlEg4KBmFjdGlvbhgBIAEoCRI+CghzbmFwc2hvdBgCIAEoCzIsLmxpdmVhZ2VudC5nYXRld2F5LnYxLk1hbmFnZWRQcm9jZXNzU25hcHNob3QSEwoLbG9nX2NvbnRlbnQYAyABKAkSEAoIbG9nX3BhdGgYBCABKAkSFQoNbG9nX3RydW5jYXRlZBgFIAEoCBIPCgdzdG9wcGVkGAYgASgIIjkKE01lbW9yeU1hbmFnZVJlcXVlc3QSDwoHY29tbWFuZBgBIAEoCRIRCglhcmdzX2pzb24YAiABKAkiKwoUTWVtb3J5TWFuYWdlUmVzcG9uc2USEwoLcmVzdWx0X2pzb24YASABKAkixgIKD1Rlcm1pbmFsUmVxdWVzdBIOCgZhY3Rpb24YASABKAkSEgoKc2Vzc2lvbl9pZBgCIAEoCRIYChBwcm9qZWN0X3BhdGhfa2V5GAMgASgJEgsKA2N3ZBgEIAEoCRINCgVzaGVsbBgFIAEoCRINCgV0aXRsZRgGIAEoCRIMCgRkYXRhGAcgASgJEgwKBGNvbHMYCCABKA0SDAoEcm93cxgJIAEoDRIRCgltYXhfYnl0ZXMYCiABKA0SEwoLc3NoX2hvc3RfaWQYCyABKAkSEQoJcHJvbXB0X2lkGAwgASgJEhUKDXByb21wdF9hbnN3ZXIYDSABKAkSFgoOdHJ1c3RfaG9zdF9rZXkYDiABKAgSFAoMc2Z0cF9lbmFibGVkGA8gASgIEg4KBnRhYl9pZBgQIAEoCRIQCgh0YWJfa2luZBgRIAEoCSKyAgoPVGVybWluYWxTZXNzaW9uEgoKAmlkGAEgASgJEhgKEHByb2plY3RfcGF0aF9rZXkYAiABKAkSCwoDY3dkGAMgASgJEg0KBXNoZWxsGAQgASgJEg0KBXRpdGxlGAUgASgJEgsKA3BpZBgGIAEoDRIMCgRjb2xzGAcgASgNEgwKBHJvd3MYCCABKA0SEgoKY3JlYXRlZF9hdBgJIAEoBBISCgp1cGRhdGVkX2F0GAogASgEEhMKC2ZpbmlzaGVkX2F0GAsgASgEEhEKCWV4aXRfY29kZRgMIAEoBRIPCgdydW5uaW5nGA0gASgIEgwKBGtpbmQYDiABKAkSNgoDc3NoGA8gASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjEuVGVybWluYWxTc2hNZXRhZGF0YSLbAQoTVGVybWluYWxTc2hNZXRhZGF0YRIPCgdob3N0X2lkGAEgASgJEhEKCWhvc3RfbmFtZRgCIAEoCRIQCgh1c2VybmFtZRgDIAEoCRIMCgRob3N0GAQgASgJEgwKBHBvcnQYBSABKA0SEQoJYXV0aF90eXBlGAYgASgJEg4KBnN0YXR1cxgHIAEoCRIZChFyZWNvbm5lY3RfYXR0ZW1wdBgIIAEoDRIeChZyZWNvbm5lY3RfbWF4X2F0dGVtcHRzGAkgASgNEhQKDHNmdHBfZW5hYmxlZBgKIAEoCCL3AQoLU2Z0cFJlcXVlc3QSDgoGYWN0aW9uGAEgASgJEhIKCnNlc3Npb25faWQYAiABKAkSGAoQcHJvamVjdF9wYXRoX2tleRgDIAEoCRIPCgd3b3JrZGlyGAQgASgJEhIKCmxvY2FsX3BhdGgYBSABKAkSEwoLcmVtb3RlX3BhdGgYBiABKAkSEQoJZnJvbV9wYXRoGAcgASgJEg8KB3RvX3BhdGgYCCABKAkSEQoJZGlyZWN0aW9uGAkgASgJEhMKC3RhcmdldF9wYXRoGAogASgJEhEKCXJlY3Vyc2l2ZRgLIAEoCBIRCglvdmVyd3JpdGUYDCABKAgiWAoJU2Z0cEVudHJ5EgwKBHBhdGgYASABKAkSDAoEbmFtZRgCIAEoCRIMCgRraW5kGAMgASgJEhIKCnNpemVfYnl0ZXMYBCABKAQSDQoFbXRpbWUYBSABKAQi8gEKDFNmdHBUcmFuc2ZlchIKCgJpZBgBIAEoCRISCgpzZXNzaW9uX2lkGAIgASgJEhEKCWRpcmVjdGlvbhgDIAEoCRIOCgZzdGF0dXMYBCABKAkSEwoLc291cmNlX3BhdGgYBSABKAkSEwoLdGFyZ2V0X3BhdGgYBiABKAkSFAoMY3VycmVudF9wYXRoGAcgASgJEhIKCmJ5dGVzX2RvbmUYCCABKAQSEwoLYnl0ZXNfdG90YWwYCSABKAQSEgoKZmlsZXNfZG9uZRgKIAEoDRITCgtmaWxlc190b3RhbBgLIAEoDRINCgVlcnJvchgMIAEoCSLUAQoMU2Z0cFJlc3BvbnNlEg4KBmFjdGlvbhgBIAEoCRIMCgRwYXRoGAIgASgJEjAKB2VudHJpZXMYAyADKAsyHy5saXZlYWdlbnQuZ2F0ZXdheS52MS5TZnRwRW50cnkSLgoFZW50cnkYBCABKAsyHy5saXZlYWdlbnQuZ2F0ZXdheS52MS5TZnRwRW50cnkSDgoGZXhpc3RzGAUgASgIEjQKCHRyYW5zZmVyGAYgASgLMiIubGl2ZWFnZW50LmdhdGV3YXkudjEuU2Z0cFRyYW5zZmVyIk8KCVNmdHBFdmVudBIMCgRraW5kGAEgASgJEjQKCHRyYW5zZmVyGAIgASgLMiIubGl2ZWFnZW50LmdhdGV3YXkudjEuU2Z0cFRyYW5zZmVyIsEBChFUZXJtaW5hbFNzaFByb21wdBIKCgJpZBgBIAEoCRIMCgRraW5kGAIgASgJEg8KB2hvc3RfaWQYAyABKAkSEQoJaG9zdF9uYW1lGAQgASgJEgwKBGhvc3QYBSABKAkSDAoEcG9ydBgGIAEoDRIPCgdtZXNzYWdlGAcgASgJEhoKEmZpbmdlcnByaW50X3NoYTI1NhgIIAEoCRIQCghrZXlfdHlwZRgJIAEoCRITCgthbnN3ZXJfZWNobxgKIAEoCCJBChNUZXJtaW5hbFNoZWxsT3B0aW9uEgoKAmlkGAEgASgJEg0KBWxhYmVsGAIgASgJEg8KB2NvbW1hbmQYAyABKAkigAEKDlRlcm1pbmFsU3NoVGFiEgoKAmlkGAEgASgJEhIKCnNlc3Npb25faWQYAiABKAkSGAoQcHJvamVjdF9wYXRoX2tleRgDIAEoCRIMCgRraW5kGAQgASgJEhIKCmNyZWF0ZWRfYXQYBSABKAQSEgoKdXBkYXRlZF9hdBgGIAEoBCJ/ChdUZXJtaW5hbFNzaFRhYnNTbmFwc2hvdBIYChBwcm9qZWN0X3BhdGhfa2V5GAEgASgJEjIKBHRhYnMYAiADKAsyJC5saXZlYWdlbnQuZ2F0ZXdheS52MS5UZXJtaW5hbFNzaFRhYhIQCghyZXZpc2lvbhgEIAEoBEoECAMQBCLZAwoQVGVybWluYWxSZXNwb25zZRIOCgZhY3Rpb24YASABKAkSNwoIc2Vzc2lvbnMYAiADKAsyJS5saXZlYWdlbnQuZ2F0ZXdheS52MS5UZXJtaW5hbFNlc3Npb24SNgoHc2Vzc2lvbhgDIAEoCzIlLmxpdmVhZ2VudC5nYXRld2F5LnYxLlRlcm1pbmFsU2Vzc2lvbhIOCgZvdXRwdXQYBCABKAwSEQoJdHJ1bmNhdGVkGAUgASgIEkAKDXNoZWxsX29wdGlvbnMYBiADKAsyKS5saXZlYWdlbnQuZ2F0ZXdheS52MS5UZXJtaW5hbFNoZWxsT3B0aW9uEhUKDWRlZmF1bHRfc2hlbGwYByABKAkSGwoTb3V0cHV0X3N0YXJ0X29mZnNldBgIIAEoBBIZChFvdXRwdXRfZW5kX29mZnNldBgJIAEoBBI7Cgpzc2hfcHJvbXB0GAogASgLMicubGl2ZWFnZW50LmdhdGV3YXkudjEuVGVybWluYWxTc2hQcm9tcHQSEgoKbGF0ZW5jeV9tcxgLIAEoDRI/Cghzc2hfdGFicxgMIAEoCzItLmxpdmVhZ2VudC5nYXRld2F5LnYxLlRlcm1pbmFsU3NoVGFic1NuYXBzaG90IooCCg1UZXJtaW5hbEV2ZW50EgwKBGtpbmQYASABKAkSEgoKc2Vzc2lvbl9pZBgCIAEoCRIYChBwcm9qZWN0X3BhdGhfa2V5GAMgASgJEjYKB3Nlc3Npb24YBCABKAsyJS5saXZlYWdlbnQuZ2F0ZXdheS52MS5UZXJtaW5hbFNlc3Npb24SDAoEZGF0YRgFIAEoDBIbChNvdXRwdXRfc3RhcnRfb2Zmc2V0GAYgASgEEhkKEW91dHB1dF9lbmRfb2Zmc2V0GAcgASgEEj8KCHNzaF90YWJzGAggASgLMi0ubGl2ZWFnZW50LmdhdGV3YXkudjEuVGVybWluYWxTc2hUYWJzU25hcHNob3QisgIKE1Rlcm1pbmFsU3RyZWFtRnJhbWUSDAoEa2luZBgBIAEoCRIRCglzdHJlYW1faWQYAiABKAkSEgoKc2Vzc2lvbl9pZBgDIAEoCRIYChBwcm9qZWN0X3BhdGhfa2V5GAQgASgJEgsKA3NlcRgFIAEoBBIUCgxzdGFydF9vZmZzZXQYBiABKAQSEgoKZW5kX29mZnNldBgHIAEoBBIMCgRjb2xzGAggASgNEgwKBHJvd3MYCSABKA0SEQoJbWF4X2J5dGVzGAogASgNEhEKCXRydW5jYXRlZBgLIAEoCBINCgVlcnJvchgMIAEoCRI2CgdzZXNzaW9uGA0gASgLMiUubGl2ZWFnZW50LmdhdGV3YXkudjEuVGVybWluYWxTZXNzaW9uEgwKBGRhdGEYDiABKAwiQAoKR2l0UmVxdWVzdBIOCgZhY3Rpb24YASABKAkSDwoHd29ya2RpchgCIAEoCRIRCglhcmdzX2pzb24YAyABKAkiMgoLR2l0UmVzcG9uc2USDgoGYWN0aW9uGAEgASgJEhMKC3Jlc3VsdF9qc29uGAIgASgJIvYCCgtDaGF0UmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkSDwoHbWVzc2FnZRgCIAEoCRI/Cg5zZWxlY3RlZF9tb2RlbBgDIAEoCzInLmxpdmVhZ2VudC5nYXRld2F5LnYxLkNoYXRTZWxlY3RlZE1vZGVsEhYKDmV4ZWN1dGlvbl9tb2RlGAQgASgJEg8KB3dvcmtkaXIYBSABKAkSHQoVc2VsZWN0ZWRfc3lzdGVtX3Rvb2xzGAYgAygJEj4KDnVwbG9hZGVkX2ZpbGVzGAcgAygLMiYubGl2ZWFnZW50LmdhdGV3YXkudjEuQ2hhdFVwbG9hZGVkRmlsZRIZChFjbGllbnRfcmVxdWVzdF9pZBgIIAEoCRJDChBydW50aW1lX2NvbnRyb2xzGAkgASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjEuQ2hhdFJ1bnRpbWVDb250cm9scxIUCgxxdWV1ZV9wb2xpY3kYCiABKAkiigEKDkNoYXRNZXNzYWdlUmVmEhUKDXNlZ21lbnRfaW5kZXgYASABKAUSFQoNbWVzc2FnZV9pbmRleBgCIAEoBRISCgpzZWdtZW50X2lkGAMgASgJEhIKCm1lc3NhZ2VfaWQYBCABKAkSDAoEcm9sZRgFIAEoCRIUCgxjb250ZW50X2hhc2gYBiABKAkiPAoRQ2FuY2VsQ2hhdFJlcXVlc3QSFwoPY29udmVyc2F0aW9uX2lkGAEgASgJEg4KBnJ1bl9pZBgCIAEoCSLPAQoSQ2hhdENvbW1hbmRSZXF1ZXN0EgwKBHR5cGUYASABKAkSMgoHcmVxdWVzdBgCIAEoCzIhLmxpdmVhZ2VudC5nYXRld2F5LnYxLkNoYXRSZXF1ZXN0Ej4KEGJhc2VfbWVzc2FnZV9yZWYYAyABKAsyJC5saXZlYWdlbnQuZ2F0ZXdheS52MS5DaGF0TWVzc2FnZVJlZhI3CgZjYW5jZWwYBCABKAsyJy5saXZlYWdlbnQuZ2F0ZXdheS52MS5DYW5jZWxDaGF0UmVxdWVzdCK4AQoQQ2hhdFF1ZXVlUmVxdWVzdBIOCgZhY3Rpb24YASABKAkSFwoPY29udmVyc2F0aW9uX2lkGAIgASgJEg8KB2l0ZW1faWQYAyABKAkSEQoJZGlyZWN0aW9uGAQgASgJEhAKCHJldmlzaW9uGAUgASgEEhIKCmRyYWZ0X2pzb24YBiABKAkSGwoTdXBsb2FkZWRfZmlsZXNfanNvbhgHIAEoCRIUCgxyZXF1ZXN0X2pzb24YCCABKAkihgEKEUNoYXRRdWV1ZVJlc3BvbnNlEhAKCGFjY2VwdGVkGAEgASgIEg8KB21lc3NhZ2UYAiABKAkSFQoNc25hcHNob3RfanNvbhgDIAEoCRIRCglpdGVtX2pzb24YBCABKAkSEgoKZXJyb3JfY29kZRgFIAEoCRIQCghyZXZpc2lvbhgGIAEoBCJSCg5DaGF0UXVldWVFdmVudBIXCg9jb252ZXJzYXRpb25faWQYASABKAkSFQoNc25hcHNob3RfanNvbhgCIAEoCRIQCghyZXZpc2lvbhgDIAEoBCKFAgoJQ2hhdEV2ZW50EjsKBHR5cGUYASABKA4yLS5saXZlYWdlbnQuZ2F0ZXdheS52MS5DaGF0RXZlbnQuQ2hhdEV2ZW50VHlwZRIXCg9jb252ZXJzYXRpb25faWQYAiABKAkSDAoEZGF0YRgDIAEoCSKTAQoNQ2hhdEV2ZW50VHlwZRIJCgVUT0tFThAAEgwKCFRISU5LSU5HEAESDQoJVE9PTF9DQUxMEAISDwoLVE9PTF9SRVNVTFQQAxIICgRET05FEAQSCQoFRVJST1IQBRIPCgtUT09MX1NUQVRVUxAGEhEKDUhPU1RFRF9TRUFSQ0gQBxIQCgxVU0VSX01FU1NBR0UQCCK8AQoQQ2hhdENvbnRyb2xFdmVudBISCgpyZXF1ZXN0X2lkGAEgASgJEhkKEWNsaWVudF9yZXF1ZXN0X2lkGAIgASgJEhcKD2NvbnZlcnNhdGlvbl9pZBgDIAEoCRIRCglydW5fZXBvY2gYBCABKAMSDAoEdHlwZRgFIAEoCRINCgVzdGF0ZRgGIAEoCRISCgplcnJvcl9jb2RlGAcgASgJEg8KB21lc3NhZ2UYCCABKAkSCwoDc2VxGAkgASgDIvwBChNDaGF0UnVudGltZVNuYXBzaG90EhcKD2NvbnZlcnNhdGlvbl9pZBgBIAEoCRIOCgZydW5faWQYAiABKAkSGQoRY2xpZW50X3JlcXVlc3RfaWQYAyABKAkSEQoJd29ya2VyX2lkGAQgASgJEg0KBXN0YXRlGAUgASgJEgsKA2N3ZBgGIAEoCRISCgp1cGRhdGVkX2F0GAcgASgDEhAKCHJldmlzaW9uGAggASgDEhQKDGVudHJpZXNfanNvbhgJIAEoCRITCgt0b29sX3N0YXR1cxgKIAEoCRIhChl0b29sX3N0YXR1c19pc19jb21wYWN0aW9uGAsgASgIIuoBChJSdW50aW1lU3RhdHVzRXZlbnQSEQoJd29ya2VyX2lkGAEgASgJEg0KBXN0YXRlGAIgASgJEg8KB3Zpc2libGUYAyABKAgSGAoQYWN0aXZlX3J1bl9jb3VudBgEIAEoDRIRCgl0aW1lc3RhbXAYBSABKAMSOAoLYWN0aXZlX3J1bnMYBiADKAsyIy5saXZlYWdlbnQuZ2F0ZXdheS52MS5DaGF0UnVuUmVwb3J0EjoKDWZpbmlzaGVkX3J1bnMYByADKAsyIy5saXZlYWdlbnQuZ2F0ZXdheS52MS5DaGF0UnVuUmVwb3J0IoABCg1DaGF0UnVuUmVwb3J0Eg4KBnJ1bl9pZBgBIAEoCRIXCg9jb252ZXJzYXRpb25faWQYAiABKAkSDQoFc3RhdGUYAyABKAkSEgoKZXJyb3JfY29kZRgEIAEoCRIPCgdtZXNzYWdlGAUgASgJEhIKCnVwZGF0ZWRfYXQYBiABKAMiRwoRQ3Jvbk1hbmFnZVJlcXVlc3QSDgoGYWN0aW9uGAEgASgJEg8KB3Rhc2tfaWQYAiABKAkSEQoJdGFza19qc29uGAMgASgJIjkKEkNyb25NYW5hZ2VSZXNwb25zZRIOCgZhY3Rpb24YASABKAkSEwoLcmVzdWx0X2pzb24YAiABKAkiVQoSSGlzdG9yeUxpc3RSZXF1ZXN0EgwKBHBhZ2UYASABKAUSEQoJcGFnZV9zaXplGAIgASgFEgsKA2N3ZBgDIAEoCRIRCgljd2RfZW1wdHkYBCABKAgibAoTSGlzdG9yeUxpc3RSZXNwb25zZRJACg1jb252ZXJzYXRpb25zGAEgAygLMikubGl2ZWFnZW50LmdhdGV3YXkudjEuQ29udmVyc2F0aW9uU3VtbWFyeRITCgt0b3RhbF9jb3VudBgCIAEoBSKKAgoTQ29udmVyc2F0aW9uU3VtbWFyeRIKCgJpZBgBIAEoCRINCgV0aXRsZRgCIAEoCRISCgpjcmVhdGVkX2F0GAMgASgDEhIKCnVwZGF0ZWRfYXQYBCABKAMSFQoNbWVzc2FnZV9jb3VudBgFIAEoBRITCgtwcm92aWRlcl9pZBgGIAEoCRINCgVtb2RlbBgHIAEoCRISCgpzZXNzaW9uX2lkGAggASgJEgsKA2N3ZBgJIAEoCRIRCglpc19waW5uZWQYCiABKAgSEQoJcGlubmVkX2F0GAsgASgDEhEKCWlzX3NoYXJlZBgMIAEoCBIbChNzZWxlY3RlZF9tb2RlbF9qc29uGA0gASgJIkIKEUhpc3RvcnlHZXRSZXF1ZXN0EhcKD2NvbnZlcnNhdGlvbl9pZBgBIAEoCRIUCgxtYXhfbWVzc2FnZXMYAiABKAUi1AEKEkhpc3RvcnlHZXRSZXNwb25zZRIXCg9jb252ZXJzYXRpb25faWQYASABKAkSFQoNbWVzc2FnZXNfanNvbhgCIAEoCRIbChN0b3RhbF9tZXNzYWdlX2NvdW50GAMgASgFEh4KFnJldHVybmVkX21lc3NhZ2VfY291bnQYBCABKAUSEAoIaGFzX21vcmUYBSABKAgSPwoMY29udmVyc2F0aW9uGAYgASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjEuQ29udmVyc2F0aW9uU3VtbWFyeSKFAQoUSGlzdG9yeVByZWZpeFJlcXVlc3QSFwoPY29udmVyc2F0aW9uX2lkGAEgASgJEhQKDG1heF9tZXNzYWdlcxgCIAEoBRI+ChBiYXNlX21lc3NhZ2VfcmVmGAMgASgLMiQubGl2ZWFnZW50LmdhdGV3YXkudjEuQ2hhdE1lc3NhZ2VSZWYi1wEKFUhpc3RvcnlQcmVmaXhSZXNwb25zZRIXCg9jb252ZXJzYXRpb25faWQYASABKAkSFQoNbWVzc2FnZXNfanNvbhgCIAEoCRIbChN0b3RhbF9tZXNzYWdlX2NvdW50GAMgASgFEh4KFnJldHVybmVkX21lc3NhZ2VfY291bnQYBCABKAUSEAoIaGFzX21vcmUYBSABKAgSPwoMY29udmVyc2F0aW9uGAYgASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjEuQ29udmVyc2F0aW9uU3VtbWFyeSI+ChRIaXN0b3J5UmVuYW1lUmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkSDQoFdGl0bGUYAiABKAkiWAoVSGlzdG9yeVJlbmFtZVJlc3BvbnNlEj8KDGNvbnZlcnNhdGlvbhgBIAEoCzIpLmxpdmVhZ2VudC5nYXRld2F5LnYxLkNvbnZlcnNhdGlvblN1bW1hcnkibwoUSGlzdG9yeUJyYW5jaFJlcXVlc3QSFwoPY29udmVyc2F0aW9uX2lkGAEgASgJEj4KEGJhc2VfbWVzc2FnZV9yZWYYAiABKAsyJC5saXZlYWdlbnQuZ2F0ZXdheS52MS5DaGF0TWVzc2FnZVJlZiJYChVIaXN0b3J5QnJhbmNoUmVzcG9uc2USPwoMY29udmVyc2F0aW9uGAEgASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjEuQ29udmVyc2F0aW9uU3VtbWFyeSI/ChFIaXN0b3J5UGluUmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkSEQoJaXNfcGlubmVkGAIgASgIIlUKEkhpc3RvcnlQaW5SZXNwb25zZRI/Cgxjb252ZXJzYXRpb24YASABKAsyKS5saXZlYWdlbnQuZ2F0ZXdheS52MS5Db252ZXJzYXRpb25TdW1tYXJ5IpIBChJIaXN0b3J5U2hhcmVTdGF0dXMSFwoPY29udmVyc2F0aW9uX2lkGAEgASgJEg8KB2VuYWJsZWQYAiABKAgSDQoFdG9rZW4YAyABKAkSEgoKY3JlYXRlZF9hdBgEIAEoAxISCgp1cGRhdGVkX2F0GAUgASgDEhsKE3JlZGFjdF90b29sX2NvbnRlbnQYBiABKAgiMQoWSGlzdG9yeVNoYXJlR2V0UmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkiUgoXSGlzdG9yeVNoYXJlR2V0UmVzcG9uc2USNwoFc2hhcmUYASABKAsyKC5saXZlYWdlbnQuZ2F0ZXdheS52MS5IaXN0b3J5U2hhcmVTdGF0dXMifAoWSGlzdG9yeVNoYXJlU2V0UmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkSDwoHZW5hYmxlZBgCIAEoCBIgChNyZWRhY3RfdG9vbF9jb250ZW50GAMgASgISACIAQFCFgoUX3JlZGFjdF90b29sX2NvbnRlbnQiUgoXSGlzdG9yeVNoYXJlU2V0UmVzcG9uc2USNwoFc2hhcmUYASABKAsyKC5saXZlYWdlbnQuZ2F0ZXdheS52MS5IaXN0b3J5U2hhcmVTdGF0dXMiKwoaSGlzdG9yeVNoYXJlUmVzb2x2ZVJlcXVlc3QSDQoFdG9rZW4YASABKAkiyAEKG0hpc3RvcnlTaGFyZVJlc29sdmVSZXNwb25zZRIXCg9jb252ZXJzYXRpb25faWQYASABKAkSFQoNbWVzc2FnZXNfanNvbhgCIAEoCRIbChN0b3RhbF9tZXNzYWdlX2NvdW50GAMgASgFEj8KDGNvbnZlcnNhdGlvbhgEIAEoCzIpLmxpdmVhZ2VudC5nYXRld2F5LnYxLkNvbnZlcnNhdGlvblN1bW1hcnkSGwoTcmVkYWN0X3Rvb2xfY29udGVudBgFIAEoCCIYChZIaXN0b3J5V29ya2RpcnNSZXF1ZXN0IlUKFUhpc3RvcnlXb3JrZGlyU3VtbWFyeRIMCgRwYXRoGAEgASgJEhoKEmNvbnZlcnNhdGlvbl9jb3VudBgCIAEoBRISCgp1cGRhdGVkX2F0GAMgASgDIlgKF0hpc3RvcnlXb3JrZGlyc1Jlc3BvbnNlEj0KCHdvcmtkaXJzGAEgAygLMisubGl2ZWFnZW50LmdhdGV3YXkudjEuSGlzdG9yeVdvcmtkaXJTdW1tYXJ5Ii8KFEhpc3RvcnlEZWxldGVSZXF1ZXN0EhcKD2NvbnZlcnNhdGlvbl9pZBgBIAEoCSIXChVIaXN0b3J5RGVsZXRlUmVzcG9uc2UiegoQSGlzdG9yeVN5bmNFdmVudBIMCgRraW5kGAEgASgJEj8KDGNvbnZlcnNhdGlvbhgCIAEoCzIpLmxpdmVhZ2VudC5nYXRld2F5LnYxLkNvbnZlcnNhdGlvblN1bW1hcnkSFwoPY29udmVyc2F0aW9uX2lkGAMgASgJIhUKE1Byb3ZpZGVyTGlzdFJlcXVlc3QiLgoUUHJvdmlkZXJMaXN0UmVzcG9uc2USFgoOcHJvdmlkZXJzX2pzb24YASABKAkiFAoSU2V0dGluZ3NHZXRSZXF1ZXN0IiwKE1NldHRpbmdzR2V0UmVzcG9uc2USFQoNc2V0dGluZ3NfanNvbhgBIAEoCSIuChVTZXR0aW5nc1VwZGF0ZVJlcXVlc3QSFQoNc2V0dGluZ3NfanNvbhgBIAEoCSI7ChZTZXR0aW5nc1VwZGF0ZVJlc3BvbnNlEhAKCGFjY2VwdGVkGAEgASgIEg8KB21lc3NhZ2UYAiABKAkiPgogU2V0dGluZ3NSZXNldFNzaEtub3duSG9zdFJlcXVlc3QSDAoEaG9zdBgBIAEoCRIMCgRwb3J0GAIgASgNIjQKIVNldHRpbmdzUmVzZXRTc2hLbm93bkhvc3RSZXNwb25zZRIPCgdkZWxldGVkGAEgASgNIioKEVNldHRpbmdzU3luY0V2ZW50EhUKDXNldHRpbmdzX2pzb24YASABKAkiFwoVU2tpbGxGaWxlc0xpc3RSZXF1ZXN0IkwKFlNraWxsRmlsZXNMaXN0UmVzcG9uc2USEAoIcm9vdF9kaXIYASABKAkSDQoFcGF0aHMYAiADKAkSEQoJdHJ1bmNhdGVkGAMgASgIIigKGFNraWxsTWV0YWRhdGFSZWFkUmVxdWVzdBIMCgRwYXRoGAEgASgJIj4KGVNraWxsTWV0YWRhdGFSZWFkUmVzcG9uc2USDAoEbmFtZRgBIAEoCRITCgtkZXNjcmlwdGlvbhgCIAEoCSJEChRTa2lsbFRleHRSZWFkUmVxdWVzdBIMCgRwYXRoGAEgASgJEg4KBm9mZnNldBgCIAEoDRIOCgZsZW5ndGgYAyABKA0iOwoVU2tpbGxUZXh0UmVhZFJlc3BvbnNlEg8KB2NvbnRlbnQYASABKAkSEQoJdHJ1bmNhdGVkGAIgASgIIioKElNraWxsTWFuYWdlUmVxdWVzdBIUCgxwYXlsb2FkX2pzb24YASABKAkiKgoTU2tpbGxNYW5hZ2VSZXNwb25zZRITCgtyZXN1bHRfanNvbhgBIAEoCSJ3ChZGaWxlTWVudGlvbkxpc3RSZXF1ZXN0Eg8KB3dvcmtkaXIYASABKAkSEwoLbWF4X3Jlc3VsdHMYAiABKA0SDQoFcXVlcnkYAyABKAkSGAoLc2hvd19oaWRkZW4YBCABKAhIAIgBAUIOCgxfc2hvd19oaWRkZW4iPgoQRmlsZU1lbnRpb25FbnRyeRIMCgRwYXRoGAEgASgJEgwKBGtpbmQYAiABKAkSDgoGaGlkZGVuGAMgASgIImUKF0ZpbGVNZW50aW9uTGlzdFJlc3BvbnNlEjcKB2VudHJpZXMYASADKAsyJi5saXZlYWdlbnQuZ2F0ZXdheS52MS5GaWxlTWVudGlvbkVudHJ5EhEKCXRydW5jYXRlZBgCIAEoCCI/CgZGc1Jvb3QSCgoCaWQYASABKAkSDAoEcGF0aBgCIAEoCRIMCgRraW5kGAMgASgJEg0KBWxhYmVsGAQgASgJIhAKDkZzUm9vdHNSZXF1ZXN0Ij4KD0ZzUm9vdHNSZXNwb25zZRIrCgVyb290cxgBIAMoCzIcLmxpdmVhZ2VudC5nYXRld2F5LnYxLkZzUm9vdCI2ChFGc0xpc3REaXJzUmVxdWVzdBIMCgRwYXRoGAEgASgJEhMKC21heF9yZXN1bHRzGAIgASgNIigKCkZzRGlyRW50cnkSDAoEcGF0aBgBIAEoCRIMCgRuYW1lGAIgASgJImgKEkZzTGlzdERpcnNSZXNwb25zZRIMCgRwYXRoGAEgASgJEjEKB2VudHJpZXMYAiADKAsyIC5saXZlYWdlbnQuZ2F0ZXdheS52MS5Gc0RpckVudHJ5EhEKCXRydW5jYXRlZBgDIAEoCCI8ChxGc0NyZWF0ZVByb2plY3RGb2xkZXJSZXF1ZXN0Eg4KBnBhcmVudBgBIAEoCRIMCgRuYW1lGAIgASgJIi0KHUZzQ3JlYXRlUHJvamVjdEZvbGRlclJlc3BvbnNlEgwKBHBhdGgYASABKAkijAEKDUZzTGlzdFJlcXVlc3QSDwoHd29ya2RpchgBIAEoCRIMCgRwYXRoGAIgASgJEg0KBWRlcHRoGAMgASgNEg4KBm9mZnNldBgEIAEoDRITCgttYXhfcmVzdWx0cxgFIAEoDRIYCgtzaG93X2hpZGRlbhgGIAEoCEgAiAEBQg4KDF9zaG93X2hpZGRlbiI5CgtGc0xpc3RFbnRyeRIMCgRwYXRoGAEgASgJEgwKBGtpbmQYAiABKAkSDgoGaGlkZGVuGAMgASgIIrkBCg5Gc0xpc3RSZXNwb25zZRIMCgRwYXRoGAEgASgJEhAKCGhhc19wYXRoGAIgASgIEg0KBWRlcHRoGAMgASgNEg4KBm9mZnNldBgEIAEoDRITCgttYXhfcmVzdWx0cxgFIAEoDRINCgV0b3RhbBgGIAEoDRIQCghoYXNfbW9yZRgHIAEoCBIyCgdlbnRyaWVzGAggAygLMiEubGl2ZWFnZW50LmdhdGV3YXkudjEuRnNMaXN0RW50cnkiOgoZRnNSZWFkRWRpdGFibGVUZXh0UmVxdWVzdBIPCgd3b3JrZGlyGAEgASgJEgwKBHBhdGgYAiABKAkijAEKGkZzUmVhZEVkaXRhYmxlVGV4dFJlc3BvbnNlEgwKBHBhdGgYASABKAkSDwoHY29udGVudBgCIAEoCRIQCghtdGltZV9tcxgDIAEoBBIUCgxjb250ZW50X2hhc2gYBCABKAkSEgoKc2l6ZV9ieXRlcxgFIAEoBBITCgt0b3RhbF9saW5lcxgGIAEoBCI8ChtGc1JlYWRXb3Jrc3BhY2VJbWFnZVJlcXVlc3QSDwoHd29ya2RpchgBIAEoCRIMCgRwYXRoGAIgASgJIokBChxGc1JlYWRXb3Jrc3BhY2VJbWFnZVJlc3BvbnNlEgwKBHBhdGgYASABKAkSEQoJbWltZV90eXBlGAIgASgJEgwKBGRhdGEYAyABKAkSEgoKc2l6ZV9ieXRlcxgEIAEoBBIQCghtdGltZV9tcxgFIAEoBBIUCgxjb250ZW50X2hhc2gYBiABKAkizgEKEkZzV3JpdGVUZXh0UmVxdWVzdBIPCgd3b3JrZGlyGAEgASgJEgwKBHBhdGgYAiABKAkSDwoHY29udGVudBgDIAEoCRIMCgRtb2RlGAQgASgJEhkKEWV4cGVjdGVkX210aW1lX21zGAUgASgEEh0KFWV4cGVjdGVkX2NvbnRlbnRfaGFzaBgGIAEoCRIdChVoYXNfZXhwZWN0ZWRfbXRpbWVfbXMYByABKAgSIQoZaGFzX2V4cGVjdGVkX2NvbnRlbnRfaGFzaBgIIAEoCCKdAQoTRnNXcml0ZVRleHRSZXNwb25zZRIMCgRwYXRoGAEgASgJEgwKBG1vZGUYAiABKAkSFgoOZXhpc3RlZF9iZWZvcmUYAyABKAgSFQoNYnl0ZXNfd3JpdHRlbhgEIAEoBBIQCghtdGltZV9tcxgFIAEoBBIUCgxjb250ZW50X2hhc2gYBiABKAkSEwoLdG90YWxfbGluZXMYByABKAQiMwoSRnNDcmVhdGVEaXJSZXF1ZXN0Eg8KB3dvcmtkaXIYASABKAkSDAoEcGF0aBgCIAEoCSIxChNGc0NyZWF0ZURpclJlc3BvbnNlEgwKBHBhdGgYASABKAkSDAoEa2luZBgCIAEoCSJGCg9Gc1JlbmFtZVJlcXVlc3QSDwoHd29ya2RpchgBIAEoCRIRCglmcm9tX3BhdGgYAiABKAkSDwoHdG9fcGF0aBgDIAEoCSJBChBGc1JlbmFtZVJlc3BvbnNlEhEKCWZyb21fcGF0aBgBIAEoCRIMCgRwYXRoGAIgASgJEgwKBGtpbmQYAyABKAkiMAoPRnNEZWxldGVSZXF1ZXN0Eg8KB3dvcmtkaXIYASABKAkSDAoEcGF0aBgCIAEoCSIuChBGc0RlbGV0ZVJlc3BvbnNlEgwKBHBhdGgYASABKAkSDAoEa2luZBgCIAEoCSIgCgtQaW5nUmVxdWVzdBIRCgl0aW1lc3RhbXAYASABKAMiIQoMUG9uZ1Jlc3BvbnNlEhEKCXRpbWVzdGFtcBgBIAEoAyIuCg1FcnJvclJlc3BvbnNlEgwKBGNvZGUYASABKAUSDwoHbWVzc2FnZRgCIAEoCSJrChVQcm92aWRlck1vZGVsc1JlcXVlc3QSFQoNcHJvdmlkZXJfdHlwZRgBIAEoCRIQCghiYXNlX3VybBgCIAEoCRIPCgdhcGlfa2V5GAMgASgJEhgKEHVzZV9zeXN0ZW1fcHJveHkYBCABKAgiLQoWUHJvdmlkZXJNb2RlbHNSZXNwb25zZRITCgttb2RlbHNfanNvbhgBIAEoCSrGBAoPVHVubmVsRnJhbWVLaW5kEiEKHVRVTk5FTF9GUkFNRV9LSU5EX1VOU1BFQ0lGSUVEEAASKAokVFVOTkVMX0ZSQU1FX0tJTkRfSFRUUF9SRVFVRVNUX1NUQVJUEAESJwojVFVOTkVMX0ZSQU1FX0tJTkRfSFRUUF9SRVFVRVNUX0JPRFkQAhImCiJUVU5ORUxfRlJBTUVfS0lORF9IVFRQX1JFUVVFU1RfRU5EEAMSKQolVFVOTkVMX0ZSQU1FX0tJTkRfSFRUUF9SRVNQT05TRV9TVEFSVBAEEigKJFRVTk5FTF9GUkFNRV9LSU5EX0hUVFBfUkVTUE9OU0VfQk9EWRAFEicKI1RVTk5FTF9GUkFNRV9LSU5EX0hUVFBfUkVTUE9OU0VfRU5EEAYSHQoZVFVOTkVMX0ZSQU1FX0tJTkRfV1NfRElBTBAHEiAKHFRVTk5FTF9GUkFNRV9LSU5EX1dTX0RJQUxfT0sQCBIjCh9UVU5ORUxfRlJBTUVfS0lORF9XU19ESUFMX0VSUk9SEAkSHgoaVFVOTkVMX0ZSQU1FX0tJTkRfV1NfRlJBTUUQChIeChpUVU5ORUxfRlJBTUVfS0lORF9XU19DTE9TRRALEhsKF1RVTk5FTF9GUkFNRV9LSU5EX0VSUk9SEAwSHAoYVFVOTkVMX0ZSQU1FX0tJTkRfQ0FOQ0VMEA0SGgoWVFVOTkVMX0ZSQU1FX0tJTkRfUElORxAOEhoKFlRVTk5FTF9GUkFNRV9LSU5EX1BPTkcQDyqBAQoTVHVubmVsV3NNZXNzYWdlVHlwZRImCiJUVU5ORUxfV1NfTUVTU0FHRV9UWVBFX1VOU1BFQ0lGSUVEEAASHwobVFVOTkVMX1dTX01FU1NBR0VfVFlQRV9URVhUEAESIQodVFVOTkVMX1dTX01FU1NBR0VfVFlQRV9CSU5BUlkQAjK8AgoMQWdlbnRHYXRld2F5El4KDEFnZW50Q29ubmVjdBIjLmxpdmVhZ2VudC5nYXRld2F5LnYxLkFnZW50RW52ZWxvcGUaJS5saXZlYWdlbnQuZ2F0ZXdheS52MS5HYXRld2F5RW52ZWxvcGUoATABEnAKFEFnZW50VGVybWluYWxDb25uZWN0EikubGl2ZWFnZW50LmdhdGV3YXkudjEuVGVybWluYWxTdHJlYW1GcmFtZRopLmxpdmVhZ2VudC5nYXRld2F5LnYxLlRlcm1pbmFsU3RyZWFtRnJhbWUoATABElUKDEF1dGhlbnRpY2F0ZRIhLmxpdmVhZ2VudC5nYXRld2F5LnYxLkF1dGhSZXF1ZXN0GiIubGl2ZWFnZW50LmdhdGV3YXkudjEuQXV0aFJlc3BvbnNlGgOIAgFCQFo+Z2l0aHViLmNvbS9saXZlYWdlbnQvYWdlbnQtZ2F0ZXdheS9pbnRlcm5hbC9wcm90by92MTtnYXRld2F5djFiBnByb3RvMw"); + fileDesc("ChZwcm90by92MS9nYXRld2F5LnByb3RvEhRsaXZlYWdlbnQuZ2F0ZXdheS52MSJFCgtBdXRoUmVxdWVzdBINCgV0b2tlbhgBIAEoCRIQCghhZ2VudF9pZBgCIAEoCRIVCg1hZ2VudF92ZXJzaW9uGAMgASgJIkQKDEF1dGhSZXNwb25zZRIPCgdzdWNjZXNzGAEgASgIEg8KB21lc3NhZ2UYAiABKAkSEgoKc2Vzc2lvbl9pZBgDIAEoCSLpGQoPR2F0ZXdheUVudmVsb3BlEhIKCnJlcXVlc3RfaWQYASABKAkSEQoJdGltZXN0YW1wGAIgASgDEkAKDGNoYXRfY29tbWFuZBgKIAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYxLkNoYXRDb21tYW5kUmVxdWVzdEgAEj4KC2Nyb25fbWFuYWdlGBQgASgLMicubGl2ZWFnZW50LmdhdGV3YXkudjEuQ3Jvbk1hbmFnZVJlcXVlc3RIABJACgxoaXN0b3J5X2xpc3QYHiABKAsyKC5saXZlYWdlbnQuZ2F0ZXdheS52MS5IaXN0b3J5TGlzdFJlcXVlc3RIABI+CgtoaXN0b3J5X2dldBgfIAEoCzInLmxpdmVhZ2VudC5nYXRld2F5LnYxLkhpc3RvcnlHZXRSZXF1ZXN0SAASRAoOaGlzdG9yeV9yZW5hbWUYICABKAsyKi5saXZlYWdlbnQuZ2F0ZXdheS52MS5IaXN0b3J5UmVuYW1lUmVxdWVzdEgAEkQKDmhpc3RvcnlfZGVsZXRlGCEgASgLMioubGl2ZWFnZW50LmdhdGV3YXkudjEuSGlzdG9yeURlbGV0ZVJlcXVlc3RIABJECg5oaXN0b3J5X3ByZWZpeBgiIAEoCzIqLmxpdmVhZ2VudC5nYXRld2F5LnYxLkhpc3RvcnlQcmVmaXhSZXF1ZXN0SAASPgoLaGlzdG9yeV9waW4YIyABKAsyJy5saXZlYWdlbnQuZ2F0ZXdheS52MS5IaXN0b3J5UGluUmVxdWVzdEgAEkkKEWhpc3Rvcnlfc2hhcmVfZ2V0GCQgASgLMiwubGl2ZWFnZW50LmdhdGV3YXkudjEuSGlzdG9yeVNoYXJlR2V0UmVxdWVzdEgAEkkKEWhpc3Rvcnlfc2hhcmVfc2V0GCUgASgLMiwubGl2ZWFnZW50LmdhdGV3YXkudjEuSGlzdG9yeVNoYXJlU2V0UmVxdWVzdEgAElEKFWhpc3Rvcnlfc2hhcmVfcmVzb2x2ZRgmIAEoCzIwLmxpdmVhZ2VudC5nYXRld2F5LnYxLkhpc3RvcnlTaGFyZVJlc29sdmVSZXF1ZXN0SAASSAoQaGlzdG9yeV93b3JrZGlycxgnIAEoCzIsLmxpdmVhZ2VudC5nYXRld2F5LnYxLkhpc3RvcnlXb3JrZGlyc1JlcXVlc3RIABJCCg1wcm92aWRlcl9saXN0GCggASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjEuUHJvdmlkZXJMaXN0UmVxdWVzdEgAEkAKDHNldHRpbmdzX2dldBgpIAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYxLlNldHRpbmdzR2V0UmVxdWVzdEgAEkYKD3NldHRpbmdzX3VwZGF0ZRgqIAEoCzIrLmxpdmVhZ2VudC5nYXRld2F5LnYxLlNldHRpbmdzVXBkYXRlUmVxdWVzdEgAEkcKEHNraWxsX2ZpbGVzX2xpc3QYKyABKAsyKy5saXZlYWdlbnQuZ2F0ZXdheS52MS5Ta2lsbEZpbGVzTGlzdFJlcXVlc3RIABJNChNza2lsbF9tZXRhZGF0YV9yZWFkGCwgASgLMi4ubGl2ZWFnZW50LmdhdGV3YXkudjEuU2tpbGxNZXRhZGF0YVJlYWRSZXF1ZXN0SAASRQoPc2tpbGxfdGV4dF9yZWFkGC0gASgLMioubGl2ZWFnZW50LmdhdGV3YXkudjEuU2tpbGxUZXh0UmVhZFJlcXVlc3RIABJJChFmaWxlX21lbnRpb25fbGlzdBguIAEoCzIsLmxpdmVhZ2VudC5nYXRld2F5LnYxLkZpbGVNZW50aW9uTGlzdFJlcXVlc3RIABJRChV1cGxvYWRfcmVhZGFibGVfZmlsZXMYLyABKAsyMC5saXZlYWdlbnQuZ2F0ZXdheS52MS5VcGxvYWRSZWFkYWJsZUZpbGVzUmVxdWVzdEgAEjgKCGZzX3Jvb3RzGDAgASgLMiQubGl2ZWFnZW50LmdhdGV3YXkudjEuRnNSb290c1JlcXVlc3RIABI/Cgxmc19saXN0X2RpcnMYMSABKAsyJy5saXZlYWdlbnQuZ2F0ZXdheS52MS5Gc0xpc3REaXJzUmVxdWVzdEgAEjEKBHBpbmcYMiABKAsyIS5saXZlYWdlbnQuZ2F0ZXdheS52MS5QaW5nUmVxdWVzdEgAElMKFnVwbG9hZGVkX2ltYWdlX3ByZXZpZXcYMyABKAsyMS5saXZlYWdlbnQuZ2F0ZXdheS52MS5VcGxvYWRlZEltYWdlUHJldmlld1JlcXVlc3RIABJCCg1tZW1vcnlfbWFuYWdlGDQgASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjEuTWVtb3J5TWFuYWdlUmVxdWVzdEgAEkAKDHNraWxsX21hbmFnZRg1IAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYxLlNraWxsTWFuYWdlUmVxdWVzdEgAElYKGGZzX2NyZWF0ZV9wcm9qZWN0X2ZvbGRlchg2IAEoCzIyLmxpdmVhZ2VudC5nYXRld2F5LnYxLkZzQ3JlYXRlUHJvamVjdEZvbGRlclJlcXVlc3RIABJBChB0ZXJtaW5hbF9yZXF1ZXN0GDcgASgLMiUubGl2ZWFnZW50LmdhdGV3YXkudjEuVGVybWluYWxSZXF1ZXN0SAASNgoHZnNfbGlzdBg4IAEoCzIjLmxpdmVhZ2VudC5nYXRld2F5LnYxLkZzTGlzdFJlcXVlc3RIABJBCg1mc193cml0ZV90ZXh0GDkgASgLMigubGl2ZWFnZW50LmdhdGV3YXkudjEuRnNXcml0ZVRleHRSZXF1ZXN0SAASQQoNZnNfY3JlYXRlX2Rpchg6IAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYxLkZzQ3JlYXRlRGlyUmVxdWVzdEgAEjoKCWZzX3JlbmFtZRg7IAEoCzIlLmxpdmVhZ2VudC5nYXRld2F5LnYxLkZzUmVuYW1lUmVxdWVzdEgAEjoKCWZzX2RlbGV0ZRg8IAEoCzIlLmxpdmVhZ2VudC5nYXRld2F5LnYxLkZzRGVsZXRlUmVxdWVzdEgAEjcKC2dpdF9yZXF1ZXN0GD0gASgLMiAubGl2ZWFnZW50LmdhdGV3YXkudjEuR2l0UmVxdWVzdEgAElAKFWZzX3JlYWRfZWRpdGFibGVfdGV4dBg+IAEoCzIvLmxpdmVhZ2VudC5nYXRld2F5LnYxLkZzUmVhZEVkaXRhYmxlVGV4dFJlcXVlc3RIABJUChdmc19yZWFkX3dvcmtzcGFjZV9pbWFnZRg/IAEoCzIxLmxpdmVhZ2VudC5nYXRld2F5LnYxLkZzUmVhZFdvcmtzcGFjZUltYWdlUmVxdWVzdEgAEjkKDHNmdHBfcmVxdWVzdBhAIAEoCzIhLmxpdmVhZ2VudC5nYXRld2F5LnYxLlNmdHBSZXF1ZXN0SAASRgoPcHJvdmlkZXJfbW9kZWxzGEEgASgLMisubGl2ZWFnZW50LmdhdGV3YXkudjEuUHJvdmlkZXJNb2RlbHNSZXF1ZXN0SAASXwodc2V0dGluZ3NfcmVzZXRfc3NoX2tub3duX2hvc3QYSCABKAsyNi5saXZlYWdlbnQuZ2F0ZXdheS52MS5TZXR0aW5nc1Jlc2V0U3NoS25vd25Ib3N0UmVxdWVzdEgAEjwKCmNoYXRfcXVldWUYSSABKAsyJi5saXZlYWdlbnQuZ2F0ZXdheS52MS5DaGF0UXVldWVSZXF1ZXN0SAASQQoMdHVubmVsX3N0YXRlGFAgASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjEuVHVubmVsU3RhdGVTbmFwc2hvdEgAEj8KD3R1bm5lbF9tdXRhdGlvbhhRIAEoCzIkLmxpdmVhZ2VudC5nYXRld2F5LnYxLlR1bm5lbE11dGF0aW9uSAASOQoMdHVubmVsX2ZyYW1lGFIgASgLMiEubGl2ZWFnZW50LmdhdGV3YXkudjEuVHVubmVsRnJhbWVIABJGCg93b3Jrc3BhY2Vfd2F0Y2gYWiABKAsyKy5saXZlYWdlbnQuZ2F0ZXdheS52MS5Xb3Jrc3BhY2VXYXRjaFJlcXVlc3RIABJOChdtYW5hZ2VkX3Byb2Nlc3NfcmVxdWVzdBhbIAEoCzIrLmxpdmVhZ2VudC5nYXRld2F5LnYxLk1hbmFnZWRQcm9jZXNzUmVxdWVzdEgAEkQKDmhpc3RvcnlfYnJhbmNoGFwgASgLMioubGl2ZWFnZW50LmdhdGV3YXkudjEuSGlzdG9yeUJyYW5jaFJlcXVlc3RIAEIJCgdwYXlsb2FkSgQIQxBESgQIRBBFSgQIRRBGSgQIShBLIqkhCg1BZ2VudEVudmVsb3BlEhIKCnJlcXVlc3RfaWQYASABKAkSEQoJdGltZXN0YW1wGAIgASgDEjUKCmNoYXRfZXZlbnQYCiABKAsyHy5saXZlYWdlbnQuZ2F0ZXdheS52MS5DaGF0RXZlbnRIABJEChBjcm9uX21hbmFnZV9yZXNwGBQgASgLMigubGl2ZWFnZW50LmdhdGV3YXkudjEuQ3Jvbk1hbmFnZVJlc3BvbnNlSAASRgoRaGlzdG9yeV9saXN0X3Jlc3AYHiABKAsyKS5saXZlYWdlbnQuZ2F0ZXdheS52MS5IaXN0b3J5TGlzdFJlc3BvbnNlSAASRAoQaGlzdG9yeV9nZXRfcmVzcBgfIAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYxLkhpc3RvcnlHZXRSZXNwb25zZUgAEkoKE2hpc3RvcnlfcmVuYW1lX3Jlc3AYICABKAsyKy5saXZlYWdlbnQuZ2F0ZXdheS52MS5IaXN0b3J5UmVuYW1lUmVzcG9uc2VIABJKChNoaXN0b3J5X2RlbGV0ZV9yZXNwGCEgASgLMisubGl2ZWFnZW50LmdhdGV3YXkudjEuSGlzdG9yeURlbGV0ZVJlc3BvbnNlSAASPgoMaGlzdG9yeV9zeW5jGCIgASgLMiYubGl2ZWFnZW50LmdhdGV3YXkudjEuSGlzdG9yeVN5bmNFdmVudEgAEkoKE2hpc3RvcnlfcHJlZml4X3Jlc3AYIyABKAsyKy5saXZlYWdlbnQuZ2F0ZXdheS52MS5IaXN0b3J5UHJlZml4UmVzcG9uc2VIABJEChBoaXN0b3J5X3Bpbl9yZXNwGCQgASgLMigubGl2ZWFnZW50LmdhdGV3YXkudjEuSGlzdG9yeVBpblJlc3BvbnNlSAASTwoWaGlzdG9yeV9zaGFyZV9nZXRfcmVzcBglIAEoCzItLmxpdmVhZ2VudC5nYXRld2F5LnYxLkhpc3RvcnlTaGFyZUdldFJlc3BvbnNlSAASTwoWaGlzdG9yeV9zaGFyZV9zZXRfcmVzcBgmIAEoCzItLmxpdmVhZ2VudC5nYXRld2F5LnYxLkhpc3RvcnlTaGFyZVNldFJlc3BvbnNlSAASVwoaaGlzdG9yeV9zaGFyZV9yZXNvbHZlX3Jlc3AYJyABKAsyMS5saXZlYWdlbnQuZ2F0ZXdheS52MS5IaXN0b3J5U2hhcmVSZXNvbHZlUmVzcG9uc2VIABJOChVoaXN0b3J5X3dvcmtkaXJzX3Jlc3AYOCABKAsyLS5saXZlYWdlbnQuZ2F0ZXdheS52MS5IaXN0b3J5V29ya2RpcnNSZXNwb25zZUgAEkgKEnByb3ZpZGVyX2xpc3RfcmVzcBgoIAEoCzIqLmxpdmVhZ2VudC5nYXRld2F5LnYxLlByb3ZpZGVyTGlzdFJlc3BvbnNlSAASRgoRc2V0dGluZ3NfZ2V0X3Jlc3AYKSABKAsyKS5saXZlYWdlbnQuZ2F0ZXdheS52MS5TZXR0aW5nc0dldFJlc3BvbnNlSAASTAoUc2V0dGluZ3NfdXBkYXRlX3Jlc3AYKiABKAsyLC5saXZlYWdlbnQuZ2F0ZXdheS52MS5TZXR0aW5nc1VwZGF0ZVJlc3BvbnNlSAASQAoNc2V0dGluZ3Nfc3luYxgrIAEoCzInLmxpdmVhZ2VudC5nYXRld2F5LnYxLlNldHRpbmdzU3luY0V2ZW50SAASTQoVc2tpbGxfZmlsZXNfbGlzdF9yZXNwGCwgASgLMiwubGl2ZWFnZW50LmdhdGV3YXkudjEuU2tpbGxGaWxlc0xpc3RSZXNwb25zZUgAElMKGHNraWxsX21ldGFkYXRhX3JlYWRfcmVzcBgtIAEoCzIvLmxpdmVhZ2VudC5nYXRld2F5LnYxLlNraWxsTWV0YWRhdGFSZWFkUmVzcG9uc2VIABJLChRza2lsbF90ZXh0X3JlYWRfcmVzcBguIAEoCzIrLmxpdmVhZ2VudC5nYXRld2F5LnYxLlNraWxsVGV4dFJlYWRSZXNwb25zZUgAEk8KFmZpbGVfbWVudGlvbl9saXN0X3Jlc3AYLyABKAsyLS5saXZlYWdlbnQuZ2F0ZXdheS52MS5GaWxlTWVudGlvbkxpc3RSZXNwb25zZUgAElcKGnVwbG9hZF9yZWFkYWJsZV9maWxlc19yZXNwGDAgASgLMjEubGl2ZWFnZW50LmdhdGV3YXkudjEuVXBsb2FkUmVhZGFibGVGaWxlc1Jlc3BvbnNlSAASPgoNZnNfcm9vdHNfcmVzcBgxIAEoCzIlLmxpdmVhZ2VudC5nYXRld2F5LnYxLkZzUm9vdHNSZXNwb25zZUgAEjIKBHBvbmcYMiABKAsyIi5saXZlYWdlbnQuZ2F0ZXdheS52MS5Qb25nUmVzcG9uc2VIABJFChFmc19saXN0X2RpcnNfcmVzcBgzIAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYxLkZzTGlzdERpcnNSZXNwb25zZUgAElkKG3VwbG9hZGVkX2ltYWdlX3ByZXZpZXdfcmVzcBg0IAEoCzIyLmxpdmVhZ2VudC5nYXRld2F5LnYxLlVwbG9hZGVkSW1hZ2VQcmV2aWV3UmVzcG9uc2VIABJIChJtZW1vcnlfbWFuYWdlX3Jlc3AYNSABKAsyKi5saXZlYWdlbnQuZ2F0ZXdheS52MS5NZW1vcnlNYW5hZ2VSZXNwb25zZUgAEkYKEXNraWxsX21hbmFnZV9yZXNwGDYgASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjEuU2tpbGxNYW5hZ2VSZXNwb25zZUgAElwKHWZzX2NyZWF0ZV9wcm9qZWN0X2ZvbGRlcl9yZXNwGDcgASgLMjMubGl2ZWFnZW50LmdhdGV3YXkudjEuRnNDcmVhdGVQcm9qZWN0Rm9sZGVyUmVzcG9uc2VIABJDChF0ZXJtaW5hbF9yZXNwb25zZRg5IAEoCzImLmxpdmVhZ2VudC5nYXRld2F5LnYxLlRlcm1pbmFsUmVzcG9uc2VIABI9Cg50ZXJtaW5hbF9ldmVudBg6IAEoCzIjLmxpdmVhZ2VudC5nYXRld2F5LnYxLlRlcm1pbmFsRXZlbnRIABI8Cgxmc19saXN0X3Jlc3AYOyABKAsyJC5saXZlYWdlbnQuZ2F0ZXdheS52MS5Gc0xpc3RSZXNwb25zZUgAEkcKEmZzX3dyaXRlX3RleHRfcmVzcBg8IAEoCzIpLmxpdmVhZ2VudC5nYXRld2F5LnYxLkZzV3JpdGVUZXh0UmVzcG9uc2VIABJHChJmc19jcmVhdGVfZGlyX3Jlc3AYPSABKAsyKS5saXZlYWdlbnQuZ2F0ZXdheS52MS5Gc0NyZWF0ZURpclJlc3BvbnNlSAASQAoOZnNfcmVuYW1lX3Jlc3AYPiABKAsyJi5saXZlYWdlbnQuZ2F0ZXdheS52MS5Gc1JlbmFtZVJlc3BvbnNlSAASQAoOZnNfZGVsZXRlX3Jlc3AYPyABKAsyJi5saXZlYWdlbnQuZ2F0ZXdheS52MS5Gc0RlbGV0ZVJlc3BvbnNlSAASOQoMZ2l0X3Jlc3BvbnNlGEAgASgLMiEubGl2ZWFnZW50LmdhdGV3YXkudjEuR2l0UmVzcG9uc2VIABJWChpmc19yZWFkX2VkaXRhYmxlX3RleHRfcmVzcBhBIAEoCzIwLmxpdmVhZ2VudC5nYXRld2F5LnYxLkZzUmVhZEVkaXRhYmxlVGV4dFJlc3BvbnNlSAASWgocZnNfcmVhZF93b3Jrc3BhY2VfaW1hZ2VfcmVzcBhCIAEoCzIyLmxpdmVhZ2VudC5nYXRld2F5LnYxLkZzUmVhZFdvcmtzcGFjZUltYWdlUmVzcG9uc2VIABI7Cg1zZnRwX3Jlc3BvbnNlGEkgASgLMiIubGl2ZWFnZW50LmdhdGV3YXkudjEuU2Z0cFJlc3BvbnNlSAASNQoKc2Z0cF9ldmVudBhKIAEoCzIfLmxpdmVhZ2VudC5nYXRld2F5LnYxLlNmdHBFdmVudEgAEkIKD2NoYXRfcXVldWVfcmVzcBhLIAEoCzInLmxpdmVhZ2VudC5nYXRld2F5LnYxLkNoYXRRdWV1ZVJlc3BvbnNlSAASQAoQY2hhdF9xdWV1ZV9ldmVudBhMIAEoCzIkLmxpdmVhZ2VudC5nYXRld2F5LnYxLkNoYXRRdWV1ZUV2ZW50SAASPgoMY2hhdF9jb250cm9sGEYgASgLMiYubGl2ZWFnZW50LmdhdGV3YXkudjEuQ2hhdENvbnRyb2xFdmVudEgAEkIKDnJ1bnRpbWVfc3RhdHVzGEcgASgLMigubGl2ZWFnZW50LmdhdGV3YXkudjEuUnVudGltZVN0YXR1c0V2ZW50SAASZQoic2V0dGluZ3NfcmVzZXRfc3NoX2tub3duX2hvc3RfcmVzcBhIIAEoCzI3LmxpdmVhZ2VudC5nYXRld2F5LnYxLlNldHRpbmdzUmVzZXRTc2hLbm93bkhvc3RSZXNwb25zZUgAEkoKFWNoYXRfcnVudGltZV9zbmFwc2hvdBhNIAEoCzIpLmxpdmVhZ2VudC5nYXRld2F5LnYxLkNoYXRSdW50aW1lU25hcHNob3RIABJMChRwcm92aWRlcl9tb2RlbHNfcmVzcBhPIAEoCzIsLmxpdmVhZ2VudC5nYXRld2F5LnYxLlByb3ZpZGVyTW9kZWxzUmVzcG9uc2VIABJCCg50dW5uZWxfZGVzaXJlZBhQIAEoCzIoLmxpdmVhZ2VudC5nYXRld2F5LnYxLlR1bm5lbERlc2lyZWRTdGF0ZUgAEkwKFnR1bm5lbF9tdXRhdGlvbl9yZXN1bHQYUSABKAsyKi5saXZlYWdlbnQuZ2F0ZXdheS52MS5UdW5uZWxNdXRhdGlvblJlc3VsdEgAEjkKDHR1bm5lbF9mcmFtZRhSIAEoCzIhLmxpdmVhZ2VudC5nYXRld2F5LnYxLlR1bm5lbEZyYW1lSAASRgoTdHVubmVsX3Byb2JlX3JlcG9ydBhTIAEoCzInLmxpdmVhZ2VudC5nYXRld2F5LnYxLlR1bm5lbFByb2JlUmVwb3J0SAASSgoSd29ya3NwYWNlX2FjdGl2aXR5GFogASgLMiwubGl2ZWFnZW50LmdhdGV3YXkudjEuV29ya3NwYWNlQWN0aXZpdHlFdmVudEgAElAKGG1hbmFnZWRfcHJvY2Vzc19yZXNwb25zZRhbIAEoCzIsLmxpdmVhZ2VudC5nYXRld2F5LnYxLk1hbmFnZWRQcm9jZXNzUmVzcG9uc2VIABJQChhtYW5hZ2VkX3Byb2Nlc3Nfc25hcHNob3QYXCABKAsyLC5saXZlYWdlbnQuZ2F0ZXdheS52MS5NYW5hZ2VkUHJvY2Vzc1NuYXBzaG90SAASSgoTaGlzdG9yeV9icmFuY2hfcmVzcBhdIAEoCzIrLmxpdmVhZ2VudC5nYXRld2F5LnYxLkhpc3RvcnlCcmFuY2hSZXNwb25zZUgAEjQKBWVycm9yGGMgASgLMiMubGl2ZWFnZW50LmdhdGV3YXkudjEuRXJyb3JSZXNwb25zZUgAQgkKB3BheWxvYWRKBAhDEERKBAhEEEVKBAhFEEZKBAhOEE8iVQoRQ2hhdFNlbGVjdGVkTW9kZWwSGgoSY3VzdG9tX3Byb3ZpZGVyX2lkGAEgASgJEg0KBW1vZGVsGAIgASgJEhUKDXByb3ZpZGVyX3R5cGUYAyABKAkiZQoTQ2hhdFJ1bnRpbWVDb250cm9scxIYChB0aGlua2luZ19lbmFibGVkGAEgASgIEiEKGW5hdGl2ZV93ZWJfc2VhcmNoX2VuYWJsZWQYAiABKAgSEQoJcmVhc29uaW5nGAMgASgJInUKEENoYXRVcGxvYWRlZEZpbGUSFQoNcmVsYXRpdmVfcGF0aBgBIAEoCRIVCg1hYnNvbHV0ZV9wYXRoGAIgASgJEhEKCWZpbGVfbmFtZRgDIAEoCRIMCgRraW5kGAQgASgJEhIKCnNpemVfYnl0ZXMYBSABKAMiSwoSVXBsb2FkUmVhZGFibGVGaWxlEhEKCWZpbGVfbmFtZRgBIAEoCRIRCgltaW1lX3R5cGUYAiABKAkSDwoHY29udGVudBgDIAEoDCJmChpVcGxvYWRSZWFkYWJsZUZpbGVzUmVxdWVzdBIPCgd3b3JrZGlyGAEgASgJEjcKBWZpbGVzGAIgAygLMigubGl2ZWFnZW50LmdhdGV3YXkudjEuVXBsb2FkUmVhZGFibGVGaWxlImUKG1VwbG9hZFJlYWRhYmxlRmlsZXNSZXNwb25zZRI1CgVmaWxlcxgBIAMoCzImLmxpdmVhZ2VudC5nYXRld2F5LnYxLkNoYXRVcGxvYWRlZEZpbGUSDwoHc2tpcHBlZBgCIAMoCSJFChtVcGxvYWRlZEltYWdlUHJldmlld1JlcXVlc3QSDwoHd29ya2RpchgBIAEoCRIVCg1hYnNvbHV0ZV9wYXRoGAIgASgJIj8KHFVwbG9hZGVkSW1hZ2VQcmV2aWV3UmVzcG9uc2USEQoJbWltZV90eXBlGAEgASgJEgwKBGRhdGEYAiABKAkiewoKVHVubmVsU3BlYxIKCgJpZBgBIAEoCRIRCglzbHVnX2hpbnQYAiABKAkSDAoEbmFtZRgDIAEoCRISCgp0YXJnZXRfdXJsGAQgASgJEhIKCmV4cGlyZXNfYXQYBSABKAMSGAoQcHJvamVjdF9wYXRoX2tleRgGIAEoCSJZChJUdW5uZWxEZXNpcmVkU3RhdGUSMQoHdHVubmVscxgBIAMoCzIgLmxpdmVhZ2VudC5nYXRld2F5LnYxLlR1bm5lbFNwZWMSEAoIcmV2aXNpb24YAiABKAQiZgoMVHVubmVsSGVhbHRoEg4KBnN0YXR1cxgBIAEoCRITCgtodHRwX3N0YXR1cxgCIAEoDRINCgVlcnJvchgDIAEoCRISCgpjaGVja2VkX2F0GAQgASgDEg4KBnJ0dF9tcxgFIAEoDSLwAQoMVHVubmVsU3RhdHVzEgoKAmlkGAEgASgJEgwKBHNsdWcYAiABKAkSDAoEbmFtZRgDIAEoCRISCgp0YXJnZXRfdXJsGAQgASgJEhMKC3B1YmxpY19wYXRoGAUgASgJEhIKCmNyZWF0ZWRfYXQYBiABKAMSEgoKZXhwaXJlc19hdBgHIAEoAxIaChJhY3RpdmVfY29ubmVjdGlvbnMYCCABKA0SGAoQcHJvamVjdF9wYXRoX2tleRgJIAEoCRIxCgVsb2NhbBgKIAEoCzIiLmxpdmVhZ2VudC5nYXRld2F5LnYxLlR1bm5lbEhlYWx0aCKlAQoTVHVubmVsU3RhdGVTbmFwc2hvdBIzCgd0dW5uZWxzGAEgAygLMiIubGl2ZWFnZW50LmdhdGV3YXkudjEuVHVubmVsU3RhdHVzEhAKCHJldmlzaW9uGAIgASgEEhQKDGFnZW50X29ubGluZRgDIAEoCBIxCgVyZWxheRgEIAEoCzIiLmxpdmVhZ2VudC5nYXRld2F5LnYxLlR1bm5lbEhlYWx0aCKZAQoOVHVubmVsTXV0YXRpb24SDgoGYWN0aW9uGAEgASgJEhEKCXR1bm5lbF9pZBgCIAEoCRISCgp0YXJnZXRfdXJsGAMgASgJEgwKBG5hbWUYBCABKAkSGAoLdHRsX3NlY29uZHMYBSABKA1IAIgBARIYChBwcm9qZWN0X3BhdGhfa2V5GAYgASgJQg4KDF90dGxfc2Vjb25kcyJUChRUdW5uZWxNdXRhdGlvblJlc3VsdBIRCgl0dW5uZWxfaWQYASABKAkSEgoKZXJyb3JfY29kZRgCIAEoCRIVCg1lcnJvcl9tZXNzYWdlGAMgASgJIlkKEVR1bm5lbFByb2JlUmVzdWx0EhEKCXR1bm5lbF9pZBgBIAEoCRIxCgVsb2NhbBgCIAEoCzIiLmxpdmVhZ2VudC5nYXRld2F5LnYxLlR1bm5lbEhlYWx0aCJNChFUdW5uZWxQcm9iZVJlcG9ydBI4CgdyZXN1bHRzGAEgAygLMicubGl2ZWFnZW50LmdhdGV3YXkudjEuVHVubmVsUHJvYmVSZXN1bHQiKwoMVHVubmVsSGVhZGVyEgwKBG5hbWUYASABKAkSDQoFdmFsdWUYAiABKAki9QIKC1R1bm5lbEZyYW1lEhEKCXN0cmVhbV9pZBgBIAEoCRIzCgRraW5kGAIgASgOMiUubGl2ZWFnZW50LmdhdGV3YXkudjEuVHVubmVsRnJhbWVLaW5kEhIKCnRhcmdldF91cmwYAyABKAkSDgoGbWV0aG9kGAQgASgJEgwKBHBhdGgYBSABKAkSMwoHaGVhZGVycxgGIAMoCzIiLmxpdmVhZ2VudC5nYXRld2F5LnYxLlR1bm5lbEhlYWRlchIOCgZzdGF0dXMYByABKA0SDAoEYm9keRgIIAEoDBINCgVlcnJvchgJIAEoCRJCCg93c19tZXNzYWdlX3R5cGUYCiABKA4yKS5saXZlYWdlbnQuZ2F0ZXdheS52MS5UdW5uZWxXc01lc3NhZ2VUeXBlEhYKDndzX3N1YnByb3RvY29sGAsgASgJEhUKDXdzX2Nsb3NlX2NvZGUYDCABKA0SFwoPd3NfY2xvc2VfcmVhc29uGA0gASgJIikKFVdvcmtzcGFjZVdhdGNoUmVxdWVzdBIQCgh3b3JrZGlycxgBIAMoCSJ+ChZXb3Jrc3BhY2VBY3Rpdml0eUV2ZW50Eg8KB3dvcmtkaXIYASABKAkSEAoIcmV2aXNpb24YAiABKAQSCgoCZnMYAyABKAgSCwoDZ2l0GAQgASgIEhUKDWNoYW5nZWRfcGF0aHMYBSADKAkSEQoJdHJ1bmNhdGVkGAYgASgIIpYCChRNYW5hZ2VkUHJvY2Vzc1JlY29yZBIKCgJpZBgBIAEoCRINCgVsYWJlbBgCIAEoCRIPCgdjb21tYW5kGAMgASgJEgsKA2N3ZBgEIAEoCRINCgVzaGVsbBgFIAEoCRILCgNwaWQYBiABKA0SEAoIbG9nX3BhdGgYByABKAkSEgoKc3RhcnRlZF9hdBgIIAEoAxIYCgtmaW5pc2hlZF9hdBgJIAEoA0gAiAEBEhYKCWV4aXRfY29kZRgKIAEoBUgBiAEBEg8KB3J1bm5pbmcYCyABKAgSEAoIaXNvbGF0ZWQYDCABKAgSEAoIcmVzdG9yZWQYDSABKAhCDgoMX2ZpbmlzaGVkX2F0QgwKCl9leGl0X2NvZGUiaQoWTWFuYWdlZFByb2Nlc3NTbmFwc2hvdBI9Cglwcm9jZXNzZXMYASADKAsyKi5saXZlYWdlbnQuZ2F0ZXdheS52MS5NYW5hZ2VkUHJvY2Vzc1JlY29yZBIQCghyZXZpc2lvbhgCIAEoBCJOChVNYW5hZ2VkUHJvY2Vzc1JlcXVlc3QSDgoGYWN0aW9uGAEgASgJEhIKCnByb2Nlc3NfaWQYAiABKAkSEQoJbWF4X2J5dGVzGAMgASgNIrcBChZNYW5hZ2VkUHJvY2Vzc1Jlc3BvbnNlEg4KBmFjdGlvbhgBIAEoCRI+CghzbmFwc2hvdBgCIAEoCzIsLmxpdmVhZ2VudC5nYXRld2F5LnYxLk1hbmFnZWRQcm9jZXNzU25hcHNob3QSEwoLbG9nX2NvbnRlbnQYAyABKAkSEAoIbG9nX3BhdGgYBCABKAkSFQoNbG9nX3RydW5jYXRlZBgFIAEoCBIPCgdzdG9wcGVkGAYgASgIIjkKE01lbW9yeU1hbmFnZVJlcXVlc3QSDwoHY29tbWFuZBgBIAEoCRIRCglhcmdzX2pzb24YAiABKAkiKwoUTWVtb3J5TWFuYWdlUmVzcG9uc2USEwoLcmVzdWx0X2pzb24YASABKAkixgIKD1Rlcm1pbmFsUmVxdWVzdBIOCgZhY3Rpb24YASABKAkSEgoKc2Vzc2lvbl9pZBgCIAEoCRIYChBwcm9qZWN0X3BhdGhfa2V5GAMgASgJEgsKA2N3ZBgEIAEoCRINCgVzaGVsbBgFIAEoCRINCgV0aXRsZRgGIAEoCRIMCgRkYXRhGAcgASgJEgwKBGNvbHMYCCABKA0SDAoEcm93cxgJIAEoDRIRCgltYXhfYnl0ZXMYCiABKA0SEwoLc3NoX2hvc3RfaWQYCyABKAkSEQoJcHJvbXB0X2lkGAwgASgJEhUKDXByb21wdF9hbnN3ZXIYDSABKAkSFgoOdHJ1c3RfaG9zdF9rZXkYDiABKAgSFAoMc2Z0cF9lbmFibGVkGA8gASgIEg4KBnRhYl9pZBgQIAEoCRIQCgh0YWJfa2luZBgRIAEoCSKyAgoPVGVybWluYWxTZXNzaW9uEgoKAmlkGAEgASgJEhgKEHByb2plY3RfcGF0aF9rZXkYAiABKAkSCwoDY3dkGAMgASgJEg0KBXNoZWxsGAQgASgJEg0KBXRpdGxlGAUgASgJEgsKA3BpZBgGIAEoDRIMCgRjb2xzGAcgASgNEgwKBHJvd3MYCCABKA0SEgoKY3JlYXRlZF9hdBgJIAEoBBISCgp1cGRhdGVkX2F0GAogASgEEhMKC2ZpbmlzaGVkX2F0GAsgASgEEhEKCWV4aXRfY29kZRgMIAEoBRIPCgdydW5uaW5nGA0gASgIEgwKBGtpbmQYDiABKAkSNgoDc3NoGA8gASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjEuVGVybWluYWxTc2hNZXRhZGF0YSLbAQoTVGVybWluYWxTc2hNZXRhZGF0YRIPCgdob3N0X2lkGAEgASgJEhEKCWhvc3RfbmFtZRgCIAEoCRIQCgh1c2VybmFtZRgDIAEoCRIMCgRob3N0GAQgASgJEgwKBHBvcnQYBSABKA0SEQoJYXV0aF90eXBlGAYgASgJEg4KBnN0YXR1cxgHIAEoCRIZChFyZWNvbm5lY3RfYXR0ZW1wdBgIIAEoDRIeChZyZWNvbm5lY3RfbWF4X2F0dGVtcHRzGAkgASgNEhQKDHNmdHBfZW5hYmxlZBgKIAEoCCL3AQoLU2Z0cFJlcXVlc3QSDgoGYWN0aW9uGAEgASgJEhIKCnNlc3Npb25faWQYAiABKAkSGAoQcHJvamVjdF9wYXRoX2tleRgDIAEoCRIPCgd3b3JrZGlyGAQgASgJEhIKCmxvY2FsX3BhdGgYBSABKAkSEwoLcmVtb3RlX3BhdGgYBiABKAkSEQoJZnJvbV9wYXRoGAcgASgJEg8KB3RvX3BhdGgYCCABKAkSEQoJZGlyZWN0aW9uGAkgASgJEhMKC3RhcmdldF9wYXRoGAogASgJEhEKCXJlY3Vyc2l2ZRgLIAEoCBIRCglvdmVyd3JpdGUYDCABKAgiWAoJU2Z0cEVudHJ5EgwKBHBhdGgYASABKAkSDAoEbmFtZRgCIAEoCRIMCgRraW5kGAMgASgJEhIKCnNpemVfYnl0ZXMYBCABKAQSDQoFbXRpbWUYBSABKAQi8gEKDFNmdHBUcmFuc2ZlchIKCgJpZBgBIAEoCRISCgpzZXNzaW9uX2lkGAIgASgJEhEKCWRpcmVjdGlvbhgDIAEoCRIOCgZzdGF0dXMYBCABKAkSEwoLc291cmNlX3BhdGgYBSABKAkSEwoLdGFyZ2V0X3BhdGgYBiABKAkSFAoMY3VycmVudF9wYXRoGAcgASgJEhIKCmJ5dGVzX2RvbmUYCCABKAQSEwoLYnl0ZXNfdG90YWwYCSABKAQSEgoKZmlsZXNfZG9uZRgKIAEoDRITCgtmaWxlc190b3RhbBgLIAEoDRINCgVlcnJvchgMIAEoCSLUAQoMU2Z0cFJlc3BvbnNlEg4KBmFjdGlvbhgBIAEoCRIMCgRwYXRoGAIgASgJEjAKB2VudHJpZXMYAyADKAsyHy5saXZlYWdlbnQuZ2F0ZXdheS52MS5TZnRwRW50cnkSLgoFZW50cnkYBCABKAsyHy5saXZlYWdlbnQuZ2F0ZXdheS52MS5TZnRwRW50cnkSDgoGZXhpc3RzGAUgASgIEjQKCHRyYW5zZmVyGAYgASgLMiIubGl2ZWFnZW50LmdhdGV3YXkudjEuU2Z0cFRyYW5zZmVyIk8KCVNmdHBFdmVudBIMCgRraW5kGAEgASgJEjQKCHRyYW5zZmVyGAIgASgLMiIubGl2ZWFnZW50LmdhdGV3YXkudjEuU2Z0cFRyYW5zZmVyIsEBChFUZXJtaW5hbFNzaFByb21wdBIKCgJpZBgBIAEoCRIMCgRraW5kGAIgASgJEg8KB2hvc3RfaWQYAyABKAkSEQoJaG9zdF9uYW1lGAQgASgJEgwKBGhvc3QYBSABKAkSDAoEcG9ydBgGIAEoDRIPCgdtZXNzYWdlGAcgASgJEhoKEmZpbmdlcnByaW50X3NoYTI1NhgIIAEoCRIQCghrZXlfdHlwZRgJIAEoCRITCgthbnN3ZXJfZWNobxgKIAEoCCJBChNUZXJtaW5hbFNoZWxsT3B0aW9uEgoKAmlkGAEgASgJEg0KBWxhYmVsGAIgASgJEg8KB2NvbW1hbmQYAyABKAkigAEKDlRlcm1pbmFsU3NoVGFiEgoKAmlkGAEgASgJEhIKCnNlc3Npb25faWQYAiABKAkSGAoQcHJvamVjdF9wYXRoX2tleRgDIAEoCRIMCgRraW5kGAQgASgJEhIKCmNyZWF0ZWRfYXQYBSABKAQSEgoKdXBkYXRlZF9hdBgGIAEoBCJ/ChdUZXJtaW5hbFNzaFRhYnNTbmFwc2hvdBIYChBwcm9qZWN0X3BhdGhfa2V5GAEgASgJEjIKBHRhYnMYAiADKAsyJC5saXZlYWdlbnQuZ2F0ZXdheS52MS5UZXJtaW5hbFNzaFRhYhIQCghyZXZpc2lvbhgEIAEoBEoECAMQBCLZAwoQVGVybWluYWxSZXNwb25zZRIOCgZhY3Rpb24YASABKAkSNwoIc2Vzc2lvbnMYAiADKAsyJS5saXZlYWdlbnQuZ2F0ZXdheS52MS5UZXJtaW5hbFNlc3Npb24SNgoHc2Vzc2lvbhgDIAEoCzIlLmxpdmVhZ2VudC5nYXRld2F5LnYxLlRlcm1pbmFsU2Vzc2lvbhIOCgZvdXRwdXQYBCABKAwSEQoJdHJ1bmNhdGVkGAUgASgIEkAKDXNoZWxsX29wdGlvbnMYBiADKAsyKS5saXZlYWdlbnQuZ2F0ZXdheS52MS5UZXJtaW5hbFNoZWxsT3B0aW9uEhUKDWRlZmF1bHRfc2hlbGwYByABKAkSGwoTb3V0cHV0X3N0YXJ0X29mZnNldBgIIAEoBBIZChFvdXRwdXRfZW5kX29mZnNldBgJIAEoBBI7Cgpzc2hfcHJvbXB0GAogASgLMicubGl2ZWFnZW50LmdhdGV3YXkudjEuVGVybWluYWxTc2hQcm9tcHQSEgoKbGF0ZW5jeV9tcxgLIAEoDRI/Cghzc2hfdGFicxgMIAEoCzItLmxpdmVhZ2VudC5nYXRld2F5LnYxLlRlcm1pbmFsU3NoVGFic1NuYXBzaG90IooCCg1UZXJtaW5hbEV2ZW50EgwKBGtpbmQYASABKAkSEgoKc2Vzc2lvbl9pZBgCIAEoCRIYChBwcm9qZWN0X3BhdGhfa2V5GAMgASgJEjYKB3Nlc3Npb24YBCABKAsyJS5saXZlYWdlbnQuZ2F0ZXdheS52MS5UZXJtaW5hbFNlc3Npb24SDAoEZGF0YRgFIAEoDBIbChNvdXRwdXRfc3RhcnRfb2Zmc2V0GAYgASgEEhkKEW91dHB1dF9lbmRfb2Zmc2V0GAcgASgEEj8KCHNzaF90YWJzGAggASgLMi0ubGl2ZWFnZW50LmdhdGV3YXkudjEuVGVybWluYWxTc2hUYWJzU25hcHNob3QisgIKE1Rlcm1pbmFsU3RyZWFtRnJhbWUSDAoEa2luZBgBIAEoCRIRCglzdHJlYW1faWQYAiABKAkSEgoKc2Vzc2lvbl9pZBgDIAEoCRIYChBwcm9qZWN0X3BhdGhfa2V5GAQgASgJEgsKA3NlcRgFIAEoBBIUCgxzdGFydF9vZmZzZXQYBiABKAQSEgoKZW5kX29mZnNldBgHIAEoBBIMCgRjb2xzGAggASgNEgwKBHJvd3MYCSABKA0SEQoJbWF4X2J5dGVzGAogASgNEhEKCXRydW5jYXRlZBgLIAEoCBINCgVlcnJvchgMIAEoCRI2CgdzZXNzaW9uGA0gASgLMiUubGl2ZWFnZW50LmdhdGV3YXkudjEuVGVybWluYWxTZXNzaW9uEgwKBGRhdGEYDiABKAwiQAoKR2l0UmVxdWVzdBIOCgZhY3Rpb24YASABKAkSDwoHd29ya2RpchgCIAEoCRIRCglhcmdzX2pzb24YAyABKAkiMgoLR2l0UmVzcG9uc2USDgoGYWN0aW9uGAEgASgJEhMKC3Jlc3VsdF9qc29uGAIgASgJIvYCCgtDaGF0UmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkSDwoHbWVzc2FnZRgCIAEoCRI/Cg5zZWxlY3RlZF9tb2RlbBgDIAEoCzInLmxpdmVhZ2VudC5nYXRld2F5LnYxLkNoYXRTZWxlY3RlZE1vZGVsEhYKDmV4ZWN1dGlvbl9tb2RlGAQgASgJEg8KB3dvcmtkaXIYBSABKAkSHQoVc2VsZWN0ZWRfc3lzdGVtX3Rvb2xzGAYgAygJEj4KDnVwbG9hZGVkX2ZpbGVzGAcgAygLMiYubGl2ZWFnZW50LmdhdGV3YXkudjEuQ2hhdFVwbG9hZGVkRmlsZRIZChFjbGllbnRfcmVxdWVzdF9pZBgIIAEoCRJDChBydW50aW1lX2NvbnRyb2xzGAkgASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjEuQ2hhdFJ1bnRpbWVDb250cm9scxIUCgxxdWV1ZV9wb2xpY3kYCiABKAkiigEKDkNoYXRNZXNzYWdlUmVmEhUKDXNlZ21lbnRfaW5kZXgYASABKAUSFQoNbWVzc2FnZV9pbmRleBgCIAEoBRISCgpzZWdtZW50X2lkGAMgASgJEhIKCm1lc3NhZ2VfaWQYBCABKAkSDAoEcm9sZRgFIAEoCRIUCgxjb250ZW50X2hhc2gYBiABKAkiPAoRQ2FuY2VsQ2hhdFJlcXVlc3QSFwoPY29udmVyc2F0aW9uX2lkGAEgASgJEg4KBnJ1bl9pZBgCIAEoCSLPAQoSQ2hhdENvbW1hbmRSZXF1ZXN0EgwKBHR5cGUYASABKAkSMgoHcmVxdWVzdBgCIAEoCzIhLmxpdmVhZ2VudC5nYXRld2F5LnYxLkNoYXRSZXF1ZXN0Ej4KEGJhc2VfbWVzc2FnZV9yZWYYAyABKAsyJC5saXZlYWdlbnQuZ2F0ZXdheS52MS5DaGF0TWVzc2FnZVJlZhI3CgZjYW5jZWwYBCABKAsyJy5saXZlYWdlbnQuZ2F0ZXdheS52MS5DYW5jZWxDaGF0UmVxdWVzdCK4AQoQQ2hhdFF1ZXVlUmVxdWVzdBIOCgZhY3Rpb24YASABKAkSFwoPY29udmVyc2F0aW9uX2lkGAIgASgJEg8KB2l0ZW1faWQYAyABKAkSEQoJZGlyZWN0aW9uGAQgASgJEhAKCHJldmlzaW9uGAUgASgEEhIKCmRyYWZ0X2pzb24YBiABKAkSGwoTdXBsb2FkZWRfZmlsZXNfanNvbhgHIAEoCRIUCgxyZXF1ZXN0X2pzb24YCCABKAkihgEKEUNoYXRRdWV1ZVJlc3BvbnNlEhAKCGFjY2VwdGVkGAEgASgIEg8KB21lc3NhZ2UYAiABKAkSFQoNc25hcHNob3RfanNvbhgDIAEoCRIRCglpdGVtX2pzb24YBCABKAkSEgoKZXJyb3JfY29kZRgFIAEoCRIQCghyZXZpc2lvbhgGIAEoBCJSCg5DaGF0UXVldWVFdmVudBIXCg9jb252ZXJzYXRpb25faWQYASABKAkSFQoNc25hcHNob3RfanNvbhgCIAEoCRIQCghyZXZpc2lvbhgDIAEoBCKFAgoJQ2hhdEV2ZW50EjsKBHR5cGUYASABKA4yLS5saXZlYWdlbnQuZ2F0ZXdheS52MS5DaGF0RXZlbnQuQ2hhdEV2ZW50VHlwZRIXCg9jb252ZXJzYXRpb25faWQYAiABKAkSDAoEZGF0YRgDIAEoCSKTAQoNQ2hhdEV2ZW50VHlwZRIJCgVUT0tFThAAEgwKCFRISU5LSU5HEAESDQoJVE9PTF9DQUxMEAISDwoLVE9PTF9SRVNVTFQQAxIICgRET05FEAQSCQoFRVJST1IQBRIPCgtUT09MX1NUQVRVUxAGEhEKDUhPU1RFRF9TRUFSQ0gQBxIQCgxVU0VSX01FU1NBR0UQCCK8AQoQQ2hhdENvbnRyb2xFdmVudBISCgpyZXF1ZXN0X2lkGAEgASgJEhkKEWNsaWVudF9yZXF1ZXN0X2lkGAIgASgJEhcKD2NvbnZlcnNhdGlvbl9pZBgDIAEoCRIRCglydW5fZXBvY2gYBCABKAMSDAoEdHlwZRgFIAEoCRINCgVzdGF0ZRgGIAEoCRISCgplcnJvcl9jb2RlGAcgASgJEg8KB21lc3NhZ2UYCCABKAkSCwoDc2VxGAkgASgDIvwBChNDaGF0UnVudGltZVNuYXBzaG90EhcKD2NvbnZlcnNhdGlvbl9pZBgBIAEoCRIOCgZydW5faWQYAiABKAkSGQoRY2xpZW50X3JlcXVlc3RfaWQYAyABKAkSEQoJd29ya2VyX2lkGAQgASgJEg0KBXN0YXRlGAUgASgJEgsKA2N3ZBgGIAEoCRISCgp1cGRhdGVkX2F0GAcgASgDEhAKCHJldmlzaW9uGAggASgDEhQKDGVudHJpZXNfanNvbhgJIAEoCRITCgt0b29sX3N0YXR1cxgKIAEoCRIhChl0b29sX3N0YXR1c19pc19jb21wYWN0aW9uGAsgASgIIuoBChJSdW50aW1lU3RhdHVzRXZlbnQSEQoJd29ya2VyX2lkGAEgASgJEg0KBXN0YXRlGAIgASgJEg8KB3Zpc2libGUYAyABKAgSGAoQYWN0aXZlX3J1bl9jb3VudBgEIAEoDRIRCgl0aW1lc3RhbXAYBSABKAMSOAoLYWN0aXZlX3J1bnMYBiADKAsyIy5saXZlYWdlbnQuZ2F0ZXdheS52MS5DaGF0UnVuUmVwb3J0EjoKDWZpbmlzaGVkX3J1bnMYByADKAsyIy5saXZlYWdlbnQuZ2F0ZXdheS52MS5DaGF0UnVuUmVwb3J0IoABCg1DaGF0UnVuUmVwb3J0Eg4KBnJ1bl9pZBgBIAEoCRIXCg9jb252ZXJzYXRpb25faWQYAiABKAkSDQoFc3RhdGUYAyABKAkSEgoKZXJyb3JfY29kZRgEIAEoCRIPCgdtZXNzYWdlGAUgASgJEhIKCnVwZGF0ZWRfYXQYBiABKAMiRwoRQ3Jvbk1hbmFnZVJlcXVlc3QSDgoGYWN0aW9uGAEgASgJEg8KB3Rhc2tfaWQYAiABKAkSEQoJdGFza19qc29uGAMgASgJIjkKEkNyb25NYW5hZ2VSZXNwb25zZRIOCgZhY3Rpb24YASABKAkSEwoLcmVzdWx0X2pzb24YAiABKAkiVQoSSGlzdG9yeUxpc3RSZXF1ZXN0EgwKBHBhZ2UYASABKAUSEQoJcGFnZV9zaXplGAIgASgFEgsKA2N3ZBgDIAEoCRIRCgljd2RfZW1wdHkYBCABKAgibAoTSGlzdG9yeUxpc3RSZXNwb25zZRJACg1jb252ZXJzYXRpb25zGAEgAygLMikubGl2ZWFnZW50LmdhdGV3YXkudjEuQ29udmVyc2F0aW9uU3VtbWFyeRITCgt0b3RhbF9jb3VudBgCIAEoBSKKAgoTQ29udmVyc2F0aW9uU3VtbWFyeRIKCgJpZBgBIAEoCRINCgV0aXRsZRgCIAEoCRISCgpjcmVhdGVkX2F0GAMgASgDEhIKCnVwZGF0ZWRfYXQYBCABKAMSFQoNbWVzc2FnZV9jb3VudBgFIAEoBRITCgtwcm92aWRlcl9pZBgGIAEoCRINCgVtb2RlbBgHIAEoCRISCgpzZXNzaW9uX2lkGAggASgJEgsKA2N3ZBgJIAEoCRIRCglpc19waW5uZWQYCiABKAgSEQoJcGlubmVkX2F0GAsgASgDEhEKCWlzX3NoYXJlZBgMIAEoCBIbChNzZWxlY3RlZF9tb2RlbF9qc29uGA0gASgJIkIKEUhpc3RvcnlHZXRSZXF1ZXN0EhcKD2NvbnZlcnNhdGlvbl9pZBgBIAEoCRIUCgxtYXhfbWVzc2FnZXMYAiABKAUi1AEKEkhpc3RvcnlHZXRSZXNwb25zZRIXCg9jb252ZXJzYXRpb25faWQYASABKAkSFQoNbWVzc2FnZXNfanNvbhgCIAEoCRIbChN0b3RhbF9tZXNzYWdlX2NvdW50GAMgASgFEh4KFnJldHVybmVkX21lc3NhZ2VfY291bnQYBCABKAUSEAoIaGFzX21vcmUYBSABKAgSPwoMY29udmVyc2F0aW9uGAYgASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjEuQ29udmVyc2F0aW9uU3VtbWFyeSKFAQoUSGlzdG9yeVByZWZpeFJlcXVlc3QSFwoPY29udmVyc2F0aW9uX2lkGAEgASgJEhQKDG1heF9tZXNzYWdlcxgCIAEoBRI+ChBiYXNlX21lc3NhZ2VfcmVmGAMgASgLMiQubGl2ZWFnZW50LmdhdGV3YXkudjEuQ2hhdE1lc3NhZ2VSZWYi1wEKFUhpc3RvcnlQcmVmaXhSZXNwb25zZRIXCg9jb252ZXJzYXRpb25faWQYASABKAkSFQoNbWVzc2FnZXNfanNvbhgCIAEoCRIbChN0b3RhbF9tZXNzYWdlX2NvdW50GAMgASgFEh4KFnJldHVybmVkX21lc3NhZ2VfY291bnQYBCABKAUSEAoIaGFzX21vcmUYBSABKAgSPwoMY29udmVyc2F0aW9uGAYgASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjEuQ29udmVyc2F0aW9uU3VtbWFyeSI+ChRIaXN0b3J5UmVuYW1lUmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkSDQoFdGl0bGUYAiABKAkiWAoVSGlzdG9yeVJlbmFtZVJlc3BvbnNlEj8KDGNvbnZlcnNhdGlvbhgBIAEoCzIpLmxpdmVhZ2VudC5nYXRld2F5LnYxLkNvbnZlcnNhdGlvblN1bW1hcnkibwoUSGlzdG9yeUJyYW5jaFJlcXVlc3QSFwoPY29udmVyc2F0aW9uX2lkGAEgASgJEj4KEGJhc2VfbWVzc2FnZV9yZWYYAiABKAsyJC5saXZlYWdlbnQuZ2F0ZXdheS52MS5DaGF0TWVzc2FnZVJlZiJYChVIaXN0b3J5QnJhbmNoUmVzcG9uc2USPwoMY29udmVyc2F0aW9uGAEgASgLMikubGl2ZWFnZW50LmdhdGV3YXkudjEuQ29udmVyc2F0aW9uU3VtbWFyeSI/ChFIaXN0b3J5UGluUmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkSEQoJaXNfcGlubmVkGAIgASgIIlUKEkhpc3RvcnlQaW5SZXNwb25zZRI/Cgxjb252ZXJzYXRpb24YASABKAsyKS5saXZlYWdlbnQuZ2F0ZXdheS52MS5Db252ZXJzYXRpb25TdW1tYXJ5IpIBChJIaXN0b3J5U2hhcmVTdGF0dXMSFwoPY29udmVyc2F0aW9uX2lkGAEgASgJEg8KB2VuYWJsZWQYAiABKAgSDQoFdG9rZW4YAyABKAkSEgoKY3JlYXRlZF9hdBgEIAEoAxISCgp1cGRhdGVkX2F0GAUgASgDEhsKE3JlZGFjdF90b29sX2NvbnRlbnQYBiABKAgiMQoWSGlzdG9yeVNoYXJlR2V0UmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkiUgoXSGlzdG9yeVNoYXJlR2V0UmVzcG9uc2USNwoFc2hhcmUYASABKAsyKC5saXZlYWdlbnQuZ2F0ZXdheS52MS5IaXN0b3J5U2hhcmVTdGF0dXMifAoWSGlzdG9yeVNoYXJlU2V0UmVxdWVzdBIXCg9jb252ZXJzYXRpb25faWQYASABKAkSDwoHZW5hYmxlZBgCIAEoCBIgChNyZWRhY3RfdG9vbF9jb250ZW50GAMgASgISACIAQFCFgoUX3JlZGFjdF90b29sX2NvbnRlbnQiUgoXSGlzdG9yeVNoYXJlU2V0UmVzcG9uc2USNwoFc2hhcmUYASABKAsyKC5saXZlYWdlbnQuZ2F0ZXdheS52MS5IaXN0b3J5U2hhcmVTdGF0dXMiKwoaSGlzdG9yeVNoYXJlUmVzb2x2ZVJlcXVlc3QSDQoFdG9rZW4YASABKAkiyAEKG0hpc3RvcnlTaGFyZVJlc29sdmVSZXNwb25zZRIXCg9jb252ZXJzYXRpb25faWQYASABKAkSFQoNbWVzc2FnZXNfanNvbhgCIAEoCRIbChN0b3RhbF9tZXNzYWdlX2NvdW50GAMgASgFEj8KDGNvbnZlcnNhdGlvbhgEIAEoCzIpLmxpdmVhZ2VudC5nYXRld2F5LnYxLkNvbnZlcnNhdGlvblN1bW1hcnkSGwoTcmVkYWN0X3Rvb2xfY29udGVudBgFIAEoCCIYChZIaXN0b3J5V29ya2RpcnNSZXF1ZXN0IlUKFUhpc3RvcnlXb3JrZGlyU3VtbWFyeRIMCgRwYXRoGAEgASgJEhoKEmNvbnZlcnNhdGlvbl9jb3VudBgCIAEoBRISCgp1cGRhdGVkX2F0GAMgASgDIlgKF0hpc3RvcnlXb3JrZGlyc1Jlc3BvbnNlEj0KCHdvcmtkaXJzGAEgAygLMisubGl2ZWFnZW50LmdhdGV3YXkudjEuSGlzdG9yeVdvcmtkaXJTdW1tYXJ5Ii8KFEhpc3RvcnlEZWxldGVSZXF1ZXN0EhcKD2NvbnZlcnNhdGlvbl9pZBgBIAEoCSIXChVIaXN0b3J5RGVsZXRlUmVzcG9uc2UiegoQSGlzdG9yeVN5bmNFdmVudBIMCgRraW5kGAEgASgJEj8KDGNvbnZlcnNhdGlvbhgCIAEoCzIpLmxpdmVhZ2VudC5nYXRld2F5LnYxLkNvbnZlcnNhdGlvblN1bW1hcnkSFwoPY29udmVyc2F0aW9uX2lkGAMgASgJIhUKE1Byb3ZpZGVyTGlzdFJlcXVlc3QiLgoUUHJvdmlkZXJMaXN0UmVzcG9uc2USFgoOcHJvdmlkZXJzX2pzb24YASABKAkiFAoSU2V0dGluZ3NHZXRSZXF1ZXN0IiwKE1NldHRpbmdzR2V0UmVzcG9uc2USFQoNc2V0dGluZ3NfanNvbhgBIAEoCSIuChVTZXR0aW5nc1VwZGF0ZVJlcXVlc3QSFQoNc2V0dGluZ3NfanNvbhgBIAEoCSI7ChZTZXR0aW5nc1VwZGF0ZVJlc3BvbnNlEhAKCGFjY2VwdGVkGAEgASgIEg8KB21lc3NhZ2UYAiABKAkiPgogU2V0dGluZ3NSZXNldFNzaEtub3duSG9zdFJlcXVlc3QSDAoEaG9zdBgBIAEoCRIMCgRwb3J0GAIgASgNIjQKIVNldHRpbmdzUmVzZXRTc2hLbm93bkhvc3RSZXNwb25zZRIPCgdkZWxldGVkGAEgASgNIioKEVNldHRpbmdzU3luY0V2ZW50EhUKDXNldHRpbmdzX2pzb24YASABKAkiFwoVU2tpbGxGaWxlc0xpc3RSZXF1ZXN0IkwKFlNraWxsRmlsZXNMaXN0UmVzcG9uc2USEAoIcm9vdF9kaXIYASABKAkSDQoFcGF0aHMYAiADKAkSEQoJdHJ1bmNhdGVkGAMgASgIIigKGFNraWxsTWV0YWRhdGFSZWFkUmVxdWVzdBIMCgRwYXRoGAEgASgJIj4KGVNraWxsTWV0YWRhdGFSZWFkUmVzcG9uc2USDAoEbmFtZRgBIAEoCRITCgtkZXNjcmlwdGlvbhgCIAEoCSJEChRTa2lsbFRleHRSZWFkUmVxdWVzdBIMCgRwYXRoGAEgASgJEg4KBm9mZnNldBgCIAEoDRIOCgZsZW5ndGgYAyABKA0iOwoVU2tpbGxUZXh0UmVhZFJlc3BvbnNlEg8KB2NvbnRlbnQYASABKAkSEQoJdHJ1bmNhdGVkGAIgASgIIioKElNraWxsTWFuYWdlUmVxdWVzdBIUCgxwYXlsb2FkX2pzb24YASABKAkiKgoTU2tpbGxNYW5hZ2VSZXNwb25zZRITCgtyZXN1bHRfanNvbhgBIAEoCSJ3ChZGaWxlTWVudGlvbkxpc3RSZXF1ZXN0Eg8KB3dvcmtkaXIYASABKAkSEwoLbWF4X3Jlc3VsdHMYAiABKA0SDQoFcXVlcnkYAyABKAkSGAoLc2hvd19oaWRkZW4YBCABKAhIAIgBAUIOCgxfc2hvd19oaWRkZW4iPgoQRmlsZU1lbnRpb25FbnRyeRIMCgRwYXRoGAEgASgJEgwKBGtpbmQYAiABKAkSDgoGaGlkZGVuGAMgASgIImUKF0ZpbGVNZW50aW9uTGlzdFJlc3BvbnNlEjcKB2VudHJpZXMYASADKAsyJi5saXZlYWdlbnQuZ2F0ZXdheS52MS5GaWxlTWVudGlvbkVudHJ5EhEKCXRydW5jYXRlZBgCIAEoCCI/CgZGc1Jvb3QSCgoCaWQYASABKAkSDAoEcGF0aBgCIAEoCRIMCgRraW5kGAMgASgJEg0KBWxhYmVsGAQgASgJIhAKDkZzUm9vdHNSZXF1ZXN0Ij4KD0ZzUm9vdHNSZXNwb25zZRIrCgVyb290cxgBIAMoCzIcLmxpdmVhZ2VudC5nYXRld2F5LnYxLkZzUm9vdCI2ChFGc0xpc3REaXJzUmVxdWVzdBIMCgRwYXRoGAEgASgJEhMKC21heF9yZXN1bHRzGAIgASgNIigKCkZzRGlyRW50cnkSDAoEcGF0aBgBIAEoCRIMCgRuYW1lGAIgASgJImgKEkZzTGlzdERpcnNSZXNwb25zZRIMCgRwYXRoGAEgASgJEjEKB2VudHJpZXMYAiADKAsyIC5saXZlYWdlbnQuZ2F0ZXdheS52MS5Gc0RpckVudHJ5EhEKCXRydW5jYXRlZBgDIAEoCCI8ChxGc0NyZWF0ZVByb2plY3RGb2xkZXJSZXF1ZXN0Eg4KBnBhcmVudBgBIAEoCRIMCgRuYW1lGAIgASgJIi0KHUZzQ3JlYXRlUHJvamVjdEZvbGRlclJlc3BvbnNlEgwKBHBhdGgYASABKAkijAEKDUZzTGlzdFJlcXVlc3QSDwoHd29ya2RpchgBIAEoCRIMCgRwYXRoGAIgASgJEg0KBWRlcHRoGAMgASgNEg4KBm9mZnNldBgEIAEoDRITCgttYXhfcmVzdWx0cxgFIAEoDRIYCgtzaG93X2hpZGRlbhgGIAEoCEgAiAEBQg4KDF9zaG93X2hpZGRlbiI5CgtGc0xpc3RFbnRyeRIMCgRwYXRoGAEgASgJEgwKBGtpbmQYAiABKAkSDgoGaGlkZGVuGAMgASgIIrkBCg5Gc0xpc3RSZXNwb25zZRIMCgRwYXRoGAEgASgJEhAKCGhhc19wYXRoGAIgASgIEg0KBWRlcHRoGAMgASgNEg4KBm9mZnNldBgEIAEoDRITCgttYXhfcmVzdWx0cxgFIAEoDRINCgV0b3RhbBgGIAEoDRIQCghoYXNfbW9yZRgHIAEoCBIyCgdlbnRyaWVzGAggAygLMiEubGl2ZWFnZW50LmdhdGV3YXkudjEuRnNMaXN0RW50cnkiOgoZRnNSZWFkRWRpdGFibGVUZXh0UmVxdWVzdBIPCgd3b3JrZGlyGAEgASgJEgwKBHBhdGgYAiABKAkijAEKGkZzUmVhZEVkaXRhYmxlVGV4dFJlc3BvbnNlEgwKBHBhdGgYASABKAkSDwoHY29udGVudBgCIAEoCRIQCghtdGltZV9tcxgDIAEoBBIUCgxjb250ZW50X2hhc2gYBCABKAkSEgoKc2l6ZV9ieXRlcxgFIAEoBBITCgt0b3RhbF9saW5lcxgGIAEoBCI8ChtGc1JlYWRXb3Jrc3BhY2VJbWFnZVJlcXVlc3QSDwoHd29ya2RpchgBIAEoCRIMCgRwYXRoGAIgASgJIokBChxGc1JlYWRXb3Jrc3BhY2VJbWFnZVJlc3BvbnNlEgwKBHBhdGgYASABKAkSEQoJbWltZV90eXBlGAIgASgJEgwKBGRhdGEYAyABKAkSEgoKc2l6ZV9ieXRlcxgEIAEoBBIQCghtdGltZV9tcxgFIAEoBBIUCgxjb250ZW50X2hhc2gYBiABKAkizgEKEkZzV3JpdGVUZXh0UmVxdWVzdBIPCgd3b3JrZGlyGAEgASgJEgwKBHBhdGgYAiABKAkSDwoHY29udGVudBgDIAEoCRIMCgRtb2RlGAQgASgJEhkKEWV4cGVjdGVkX210aW1lX21zGAUgASgEEh0KFWV4cGVjdGVkX2NvbnRlbnRfaGFzaBgGIAEoCRIdChVoYXNfZXhwZWN0ZWRfbXRpbWVfbXMYByABKAgSIQoZaGFzX2V4cGVjdGVkX2NvbnRlbnRfaGFzaBgIIAEoCCKdAQoTRnNXcml0ZVRleHRSZXNwb25zZRIMCgRwYXRoGAEgASgJEgwKBG1vZGUYAiABKAkSFgoOZXhpc3RlZF9iZWZvcmUYAyABKAgSFQoNYnl0ZXNfd3JpdHRlbhgEIAEoBBIQCghtdGltZV9tcxgFIAEoBBIUCgxjb250ZW50X2hhc2gYBiABKAkSEwoLdG90YWxfbGluZXMYByABKAQiMwoSRnNDcmVhdGVEaXJSZXF1ZXN0Eg8KB3dvcmtkaXIYASABKAkSDAoEcGF0aBgCIAEoCSIxChNGc0NyZWF0ZURpclJlc3BvbnNlEgwKBHBhdGgYASABKAkSDAoEa2luZBgCIAEoCSJGCg9Gc1JlbmFtZVJlcXVlc3QSDwoHd29ya2RpchgBIAEoCRIRCglmcm9tX3BhdGgYAiABKAkSDwoHdG9fcGF0aBgDIAEoCSJBChBGc1JlbmFtZVJlc3BvbnNlEhEKCWZyb21fcGF0aBgBIAEoCRIMCgRwYXRoGAIgASgJEgwKBGtpbmQYAyABKAkiMAoPRnNEZWxldGVSZXF1ZXN0Eg8KB3dvcmtkaXIYASABKAkSDAoEcGF0aBgCIAEoCSIuChBGc0RlbGV0ZVJlc3BvbnNlEgwKBHBhdGgYASABKAkSDAoEa2luZBgCIAEoCSIgCgtQaW5nUmVxdWVzdBIRCgl0aW1lc3RhbXAYASABKAMiIQoMUG9uZ1Jlc3BvbnNlEhEKCXRpbWVzdGFtcBgBIAEoAyIuCg1FcnJvclJlc3BvbnNlEgwKBGNvZGUYASABKAUSDwoHbWVzc2FnZRgCIAEoCSJrChVQcm92aWRlck1vZGVsc1JlcXVlc3QSFQoNcHJvdmlkZXJfdHlwZRgBIAEoCRIQCghiYXNlX3VybBgCIAEoCRIPCgdhcGlfa2V5GAMgASgJEhgKEHVzZV9zeXN0ZW1fcHJveHkYBCABKAgiLQoWUHJvdmlkZXJNb2RlbHNSZXNwb25zZRITCgttb2RlbHNfanNvbhgBIAEoCSrGBAoPVHVubmVsRnJhbWVLaW5kEiEKHVRVTk5FTF9GUkFNRV9LSU5EX1VOU1BFQ0lGSUVEEAASKAokVFVOTkVMX0ZSQU1FX0tJTkRfSFRUUF9SRVFVRVNUX1NUQVJUEAESJwojVFVOTkVMX0ZSQU1FX0tJTkRfSFRUUF9SRVFVRVNUX0JPRFkQAhImCiJUVU5ORUxfRlJBTUVfS0lORF9IVFRQX1JFUVVFU1RfRU5EEAMSKQolVFVOTkVMX0ZSQU1FX0tJTkRfSFRUUF9SRVNQT05TRV9TVEFSVBAEEigKJFRVTk5FTF9GUkFNRV9LSU5EX0hUVFBfUkVTUE9OU0VfQk9EWRAFEicKI1RVTk5FTF9GUkFNRV9LSU5EX0hUVFBfUkVTUE9OU0VfRU5EEAYSHQoZVFVOTkVMX0ZSQU1FX0tJTkRfV1NfRElBTBAHEiAKHFRVTk5FTF9GUkFNRV9LSU5EX1dTX0RJQUxfT0sQCBIjCh9UVU5ORUxfRlJBTUVfS0lORF9XU19ESUFMX0VSUk9SEAkSHgoaVFVOTkVMX0ZSQU1FX0tJTkRfV1NfRlJBTUUQChIeChpUVU5ORUxfRlJBTUVfS0lORF9XU19DTE9TRRALEhsKF1RVTk5FTF9GUkFNRV9LSU5EX0VSUk9SEAwSHAoYVFVOTkVMX0ZSQU1FX0tJTkRfQ0FOQ0VMEA0SGgoWVFVOTkVMX0ZSQU1FX0tJTkRfUElORxAOEhoKFlRVTk5FTF9GUkFNRV9LSU5EX1BPTkcQDyqBAQoTVHVubmVsV3NNZXNzYWdlVHlwZRImCiJUVU5ORUxfV1NfTUVTU0FHRV9UWVBFX1VOU1BFQ0lGSUVEEAASHwobVFVOTkVMX1dTX01FU1NBR0VfVFlQRV9URVhUEAESIQodVFVOTkVMX1dTX01FU1NBR0VfVFlQRV9CSU5BUlkQAkJAWj5naXRodWIuY29tL2xpdmVhZ2VudC9hZ2VudC1nYXRld2F5L2ludGVybmFsL3Byb3RvL3YxO2dhdGV3YXl2MWIGcHJvdG8z"); /** * @generated from message liveagent.gateway.v1.AuthRequest @@ -5173,39 +5173,3 @@ export enum TunnelWsMessageType { export const TunnelWsMessageTypeSchema: GenEnum = /*@__PURE__*/ enumDesc(file_proto_v1_gateway, 1); -/** - * AgentGateway 是 v1 的桌面端 gRPC 服务。已弃用:v2 起由 /ws/v2/agent 与 /ws/v2/terminal - * (见 proto/v2/gateway_ws.proto)承担,仅为旧版桌面客户端保留,流量归零后删除; - * 本文件的消息是 v2 的业务载荷,全部保留、永不弃用。 - * - * @generated from service liveagent.gateway.v1.AgentGateway - * @deprecated - */ -export const AgentGateway: GenService<{ - /** - * @generated from rpc liveagent.gateway.v1.AgentGateway.AgentConnect - */ - agentConnect: { - methodKind: "bidi_streaming"; - input: typeof AgentEnvelopeSchema; - output: typeof GatewayEnvelopeSchema; - }, - /** - * @generated from rpc liveagent.gateway.v1.AgentGateway.AgentTerminalConnect - */ - agentTerminalConnect: { - methodKind: "bidi_streaming"; - input: typeof TerminalStreamFrameSchema; - output: typeof TerminalStreamFrameSchema; - }, - /** - * @generated from rpc liveagent.gateway.v1.AgentGateway.Authenticate - */ - authenticate: { - methodKind: "unary"; - input: typeof AuthRequestSchema; - output: typeof AuthResponseSchema; - }, -}> = /*@__PURE__*/ - serviceDesc(file_proto_v1_gateway, 0); - diff --git a/crates/agent-gateway/web/src/lib/settings/index.ts b/crates/agent-gateway/web/src/lib/settings/index.ts index 49f81cba1..39fc9dbed 100644 --- a/crates/agent-gateway/web/src/lib/settings/index.ts +++ b/crates/agent-gateway/web/src/lib/settings/index.ts @@ -963,7 +963,7 @@ export function normalizeRemoteSettings(input: unknown): RemoteSettings { return { enabled: obj.enabled === true, gatewayUrl: normalizeBaseUrl(typeof obj.gatewayUrl === "string" ? obj.gatewayUrl : ""), - grpcPort: normalizeIntegerInRange(obj.grpcPort, 1, 65_535, 50051), + grpcPort: normalizeIntegerInRange(obj.grpcPort, 1, 65_535, 443), grpcEndpoint: normalizeGrpcEndpoint(obj.grpcEndpoint), token: normalizeApiKey(typeof obj.token === "string" ? obj.token : ""), agentId: normalizeOptionalText(obj.agentId), @@ -2079,7 +2079,7 @@ export function getDefaultSettings(): AppSettings { remote: { enabled: false, gatewayUrl: "", - grpcPort: 50051, + grpcPort: 443, grpcEndpoint: "", token: "", agentId: "", diff --git a/crates/agent-gateway/web/src/pages/settings/RemoteSection.tsx b/crates/agent-gateway/web/src/pages/settings/RemoteSection.tsx index eb4818bb7..49af494fc 100644 --- a/crates/agent-gateway/web/src/pages/settings/RemoteSection.tsx +++ b/crates/agent-gateway/web/src/pages/settings/RemoteSection.tsx @@ -154,28 +154,20 @@ function usePositiveIntegerDraft( }; } -function buildGrpcEndpoint(settings: AppSettings["remote"]) { - const explicitEndpoint = settings.grpcEndpoint.trim(); - if (explicitEndpoint) { - if (/^https?:\/\//i.test(explicitEndpoint)) { - return explicitEndpoint.replace(/\/$/, ""); - } - return `http://${explicitEndpoint.replace(/\/$/, "")}`; - } - +function buildGatewayEndpointPreview(settings: AppSettings["remote"]) { const gatewayUrl = settings.gatewayUrl.trim(); if (!gatewayUrl) return ""; try { const url = new URL(gatewayUrl); - const port = String(settings.grpcPort || 50051); + const port = String(settings.grpcPort || 443); url.port = port; url.pathname = ""; url.search = ""; url.hash = ""; return url.toString().replace(/\/$/, ""); } catch { - return `${gatewayUrl}:${settings.grpcPort || 50051}`; + return `${gatewayUrl}:${settings.grpcPort || 443}`; } } @@ -244,7 +236,10 @@ export function RemoteSection(props: SettingsSectionProps) { }, [settings.remote.enabled]); const isConnected = Boolean(status.online); - const grpcEndpoint = useMemo(() => buildGrpcEndpoint(settings.remote), [settings.remote]); + const gatewayEndpointPreview = useMemo( + () => buildGatewayEndpointPreview(settings.remote), + [settings.remote], + ); const statusText = isConnected ? t("settings.remoteConnected") @@ -326,38 +321,18 @@ export function RemoteSection(props: SettingsSectionProps) { value={remoteGrpcPortDraft.draft} onBlur={remoteGrpcPortDraft.handleBlur} onChange={(e) => remoteGrpcPortDraft.handleChange(e.target.value)} - placeholder="50051" + placeholder="443" className="w-24 shrink-0 font-mono text-[13px]" />

{t("settings.remoteGatewayUrlHint")}

-
- - - updateRemoteSettings(setSettings, { - grpcEndpoint: e.target.value, - }) - } - placeholder="http://tcp.proxy.rlwy.net:12345" - className="font-mono text-[13px]" - /> -

- {t("settings.remoteGrpcEndpointHint")} -

-
- {grpcEndpoint ? ( + {gatewayEndpointPreview ? (
- {grpcEndpoint} - + {gatewayEndpointPreview} +
) : null} diff --git a/crates/agent-gui/src-tauri/Cargo.toml b/crates/agent-gui/src-tauri/Cargo.toml index 334ce8965..f5c54f0e9 100644 --- a/crates/agent-gui/src-tauri/Cargo.toml +++ b/crates/agent-gui/src-tauri/Cargo.toml @@ -49,8 +49,6 @@ rusqlite = { version = "0.40.1", features = ["bundled"] } leveldb-core = "0.1.0" chrono = "0.4.45" tokio-cron-scheduler = "0.15.1" -tonic = { version = "0.14.6", features = ["transport", "tls-ring", "tls-webpki-roots"] } -tonic-prost = "0.14.6" rustls = { version = "0.23.41", default-features = false, features = ["ring", "std"] } prost = "0.14.4" zip = { version = "8.6.0", default-features = false, features = ["deflate"] } diff --git a/crates/agent-gui/src-tauri/build.rs b/crates/agent-gui/src-tauri/build.rs index 4ac058a4d..62f319bbc 100644 --- a/crates/agent-gui/src-tauri/build.rs +++ b/crates/agent-gui/src-tauri/build.rs @@ -32,13 +32,18 @@ fn main() { .join("..") .join("agent-gateway"); let proto_v1 = gateway_root.join("proto").join("v1").join("gateway.proto"); - let proto_v2 = gateway_root.join("proto").join("v2").join("gateway_ws.proto"); + let proto_v2 = gateway_root + .join("proto") + .join("v2") + .join("gateway_ws.proto"); println!("cargo:rerun-if-changed={}", proto_v1.display()); println!("cargo:rerun-if-changed={}", proto_v2.display()); tonic_prost_build::configure() .build_server(false) + // v1 gRPC 服务已随 v1 协议移除,proto 只含消息;桌面端仅消费消息类型。 + .build_client(false) .compile_protos(&[proto_v1, proto_v2], &[gateway_root]) .expect("compile gateway protos"); diff --git a/crates/agent-gui/src-tauri/src/commands/app/system.rs b/crates/agent-gui/src-tauri/src/commands/app/system.rs index c8a540f63..11491da7b 100644 --- a/crates/agent-gui/src-tauri/src/commands/app/system.rs +++ b/crates/agent-gui/src-tauri/src/commands/app/system.rs @@ -1615,5 +1615,4 @@ mod tests { "archive" ); } - } diff --git a/crates/agent-gui/src-tauri/src/commands/app/update.rs b/crates/agent-gui/src-tauri/src/commands/app/update.rs index 7e717cc91..21644d880 100644 --- a/crates/agent-gui/src-tauri/src/commands/app/update.rs +++ b/crates/agent-gui/src-tauri/src/commands/app/update.rs @@ -570,8 +570,8 @@ pub fn app_restart(app: AppHandle) -> Result<(), String> { { registry.shutdown_cleanup(); } - if let Some(power) = app - .try_state::>() + if let Some(power) = + app.try_state::>() { power.clear_all(); } diff --git a/crates/agent-gui/src-tauri/src/commands/automation/hook.rs b/crates/agent-gui/src-tauri/src/commands/automation/hook.rs index 69e0c64c5..e24689054 100644 --- a/crates/agent-gui/src-tauri/src/commands/automation/hook.rs +++ b/crates/agent-gui/src-tauri/src/commands/automation/hook.rs @@ -331,10 +331,7 @@ mod tests { script.to_string(), None, Some(format!("scope-{}", Uuid::new_v4())), - vec![( - "LIVEAGENT_HOOK_EVENT".to_string(), - "agent_end".to_string(), - )], + vec![("LIVEAGENT_HOOK_EVENT".to_string(), "agent_end".to_string())], ) .expect("run hook script"); assert_eq!(result.exit_code, 0); diff --git a/crates/agent-gui/src-tauri/src/commands/config/settings/remote.rs b/crates/agent-gui/src-tauri/src/commands/config/settings/remote.rs index c79c5b770..e9a71342f 100644 --- a/crates/agent-gui/src-tauri/src/commands/config/settings/remote.rs +++ b/crates/agent-gui/src-tauri/src/commands/config/settings/remote.rs @@ -1,5 +1,7 @@ fn default_remote_grpc_port() -> u16 { - 50051 + // v1 gRPC 监听(:50051)已随 v1 协议删除;默认对齐网关 HTTP 默认端口, + // v2 WebSocket 经该端口建连(字段名 grpc_port 为 v1 命名遗留,实义网关端口)。 + 443 } fn default_remote_auto_reconnect() -> bool { diff --git a/crates/agent-gui/src-tauri/src/commands/integration/mcp.rs b/crates/agent-gui/src-tauri/src/commands/integration/mcp.rs index 3e1eea201..8d420a423 100644 --- a/crates/agent-gui/src-tauri/src/commands/integration/mcp.rs +++ b/crates/agent-gui/src-tauri/src/commands/integration/mcp.rs @@ -1856,9 +1856,7 @@ mod tests { manager .ensure_client(offline_http_config("server-b")) .expect("ensure server-b"); - let status = manager - .runtime_status("server-b") - .expect("status server-b"); + let status = manager.runtime_status("server-b").expect("status server-b"); assert_eq!(status.server_id, "server-b"); assert!(manager.stop_client("server-b").expect("stop server-b")); assert!( diff --git a/crates/agent-gui/src-tauri/src/commands/workspace/fs.rs b/crates/agent-gui/src-tauri/src/commands/workspace/fs.rs index 8209663d8..577fff8c3 100644 --- a/crates/agent-gui/src-tauri/src/commands/workspace/fs.rs +++ b/crates/agent-gui/src-tauri/src/commands/workspace/fs.rs @@ -61,7 +61,10 @@ enum FsError { OutOfBounds(String), #[error("{path} does not exist")] - NotFound { path: String, did_you_mean: Vec }, + NotFound { + path: String, + did_you_mean: Vec, + }, #[error("{path} is not a directory")] NotADirectory { path: String, entry_kind: String }, @@ -171,25 +174,37 @@ impl From for FsCommandError { let message = err.to_string(); let (code, path, entry_kind, did_you_mean) = match err { FsError::InvalidWorkdir(_) => (FsErrorCode::InvalidWorkdir, None, None, Vec::new()), - FsError::InvalidRelPath(path) => (FsErrorCode::InvalidPath, Some(path), None, Vec::new()), + FsError::InvalidRelPath(path) => { + (FsErrorCode::InvalidPath, Some(path), None, Vec::new()) + } FsError::OutOfBounds(path) => (FsErrorCode::OutOfBounds, Some(path), None, Vec::new()), FsError::NotFound { path, did_you_mean } => { (FsErrorCode::NotFound, Some(path), None, did_you_mean) } - FsError::NotADirectory { path, entry_kind } => { - (FsErrorCode::NotADirectory, Some(path), Some(entry_kind), Vec::new()) - } - FsError::NotAFile { path, entry_kind } => { - (FsErrorCode::NotAFile, Some(path), Some(entry_kind), Vec::new()) - } + FsError::NotADirectory { path, entry_kind } => ( + FsErrorCode::NotADirectory, + Some(path), + Some(entry_kind), + Vec::new(), + ), + FsError::NotAFile { path, entry_kind } => ( + FsErrorCode::NotAFile, + Some(path), + Some(entry_kind), + Vec::new(), + ), FsError::UnsupportedTarget { path, .. } => { (FsErrorCode::UnsupportedTarget, Some(path), None, Vec::new()) } FsError::RequiresFullRead { path } => { (FsErrorCode::RequiresFullRead, Some(path), None, Vec::new()) } - FsError::StaleFile { path, .. } => (FsErrorCode::StaleFile, Some(path), None, Vec::new()), - FsError::EditNoMatch { path } => (FsErrorCode::EditNoMatch, Some(path), None, Vec::new()), + FsError::StaleFile { path, .. } => { + (FsErrorCode::StaleFile, Some(path), None, Vec::new()) + } + FsError::EditNoMatch { path } => { + (FsErrorCode::EditNoMatch, Some(path), None, Vec::new()) + } FsError::EditAmbiguous { path, .. } => { (FsErrorCode::EditAmbiguous, Some(path), None, Vec::new()) } @@ -301,7 +316,9 @@ fn nearest_entries(parent: &Path, missing_name: &str, limit: usize) -> Vec Vec FsError { @@ -1291,11 +1312,12 @@ fn read_local_preview_file(target: PathBuf, logical_path: String) -> Result Result { let bytes = source.as_bytes().to_vec(); validate_image_size(label, bytes.len())?; if !looks_like_svg(&bytes) { - return Err(FsError::Other(format!("{label} is not valid SVG image data"))); + return Err(FsError::Other(format!( + "{label} is not valid SVG image data" + ))); } let mime_type = resolve_supported_image_mime(label, Some("image/svg+xml"), None, &bytes)?; let display_label = format!("inline-svg:{mime_type}:{} bytes", bytes.len()); @@ -2414,8 +2438,7 @@ fn fs_read_image_source_sync( source_type: Option, mime_type: Option, ) -> Result { - fs_read_image_source_impl(workdir, source, source_type, mime_type) - .map_err(FsCommandError::from) + fs_read_image_source_impl(workdir, source, source_type, mime_type).map_err(FsCommandError::from) } fn fs_read_image_source_impl( @@ -2478,8 +2501,7 @@ pub(crate) fn fs_read_workspace_image_sync( path: String, ) -> Result { let wd = canonicalize_workdir(&workdir)?; - fs_read_workspace_image_impl(&wd, &path) - .map_err(|e| FsCommandError::from(e).with_workdir(&wd)) + fs_read_workspace_image_impl(&wd, &path).map_err(|e| FsCommandError::from(e).with_workdir(&wd)) } fn fs_read_workspace_image_impl(wd: &Path, path: &str) -> Result { @@ -2939,7 +2961,9 @@ fn fs_write_text_impl( let expected = parse_expected_version(expected_mtime_ms, expected_content_hash)?; if mode != "rewrite" { - return Err(FsError::Other("Write.mode only supports rewrite".to_string())); + return Err(FsError::Other( + "Write.mode only supports rewrite".to_string(), + )); } let (target, existed_before) = match fs::symlink_metadata(&raw_target) { @@ -3053,9 +3077,11 @@ fn fs_edit_text_impl( let rel = sanitize_rel_path(path)?; let logical_path = logical_rel_path(&rel); let target = resolve_existing_file_target(wd, &rel)?; - let expected = parse_expected_version(expected_mtime_ms, expected_content_hash)? - .ok_or_else(|| FsError::RequiresFullRead { - path: logical_path.clone(), + let expected = + parse_expected_version(expected_mtime_ms, expected_content_hash)?.ok_or_else(|| { + FsError::RequiresFullRead { + path: logical_path.clone(), + } })?; if old_string.is_empty() { @@ -3378,8 +3404,7 @@ pub(crate) fn fs_rename_sync( to_path: String, ) -> Result { let wd = canonicalize_workdir(&workdir)?; - fs_rename_impl(&wd, &from_path, &to_path) - .map_err(|e| FsCommandError::from(e).with_workdir(&wd)) + fs_rename_impl(&wd, &from_path, &to_path).map_err(|e| FsCommandError::from(e).with_workdir(&wd)) } fn fs_rename_impl(wd: &Path, from_path: &str, to_path: &str) -> Result { @@ -3690,12 +3715,7 @@ fn build_workspace_walker( } fn build_ignore_walker(base: &Path, max_depth: Option) -> ignore::Walk { - build_workspace_walker( - base, - max_depth, - requested_visibility(None, true), - false, - ) + build_workspace_walker(base, max_depth, requested_visibility(None, true), false) } fn visible_paths_for_hidden_marking( @@ -3885,7 +3905,10 @@ fn fs_glob_impl( }); }; let base = if target_kind == "file" { - target.parent().map(Path::to_path_buf).unwrap_or_else(|| wd.to_path_buf()) + target + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| wd.to_path_buf()) } else { target }; @@ -3897,7 +3920,9 @@ fn fs_glob_impl( let sort_by = sort_by.unwrap_or_else(|| "path".to_string()); if sort_by != "path" { - return Err(FsError::Other("Glob.sort_by only supports path".to_string())); + return Err(FsError::Other( + "Glob.sort_by only supports path".to_string(), + )); } let mut builder = GlobSetBuilder::new(); @@ -4539,14 +4564,9 @@ mod tests { } fn mention_test_entries(workdir: &Path, show_hidden: Option) -> Vec { - fs_mention_list_sync( - workdir.display().to_string(), - Some(100), - None, - show_hidden, - ) - .expect("mention list should succeed") - .entries + fs_mention_list_sync(workdir.display().to_string(), Some(100), None, show_hidden) + .expect("mention list should succeed") + .entries } fn png_like_bytes() -> Vec { @@ -4809,7 +4829,10 @@ mod tests { "expected invalid path error for {path:?}, got {:?}", error.code ); - assert!(!error.message.trim().is_empty(), "expected error for {path:?}"); + assert!( + !error.message.trim().is_empty(), + "expected error for {path:?}" + ); } let _ = fs::remove_dir_all(workdir); @@ -4935,7 +4958,10 @@ mod tests { ); assert_eq!(decode_xml_entities("A中"), "A中"); // 非法实体走宽容降级:保留原文。 - assert_eq!(decode_xml_entities("keep &bogus; text"), "keep &bogus; text"); + assert_eq!( + decode_xml_entities("keep &bogus; text"), + "keep &bogus; text" + ); assert_eq!(decode_xml_entities("no entities"), "no entities"); } @@ -5199,7 +5225,10 @@ mod tests { for path in ["", "/tmp/liveagent-outside", "../outside", "src"] { let error = fs_create_dir_sync(workdir.display().to_string(), path.to_string()) .expect_err("invalid or existing target should fail"); - assert!(!error.message.trim().is_empty(), "expected error for {path:?}"); + assert!( + !error.message.trim().is_empty(), + "expected error for {path:?}" + ); } let _ = fs::remove_dir_all(workdir); @@ -5382,8 +5411,12 @@ mod tests { fs::write(workdir.join("ignored_dir/child.txt"), "ignored").expect("write ignored child"); let legacy_entries = list_test_entries(&workdir, None); - assert!(legacy_entries.iter().any(|entry| entry.path == ".hidden.txt")); - assert!(!legacy_entries.iter().any(|entry| entry.path == "ignored.txt")); + assert!(legacy_entries + .iter() + .any(|entry| entry.path == ".hidden.txt")); + assert!(!legacy_entries + .iter() + .any(|entry| entry.path == "ignored.txt")); let hidden_entries = list_test_entries(&workdir, Some(false)); let hidden_paths = hidden_entries @@ -5425,14 +5458,14 @@ mod tests { assert!(status.success(), "chflags hidden failed"); let hidden_entries = list_test_entries(&workdir, Some(false)); - assert!(!hidden_entries.iter().any(|entry| entry.path == "finder-hidden.txt")); + assert!(!hidden_entries + .iter() + .any(|entry| entry.path == "finder-hidden.txt")); let shown_entries = list_test_entries(&workdir, Some(true)); - assert!( - shown_entries - .iter() - .any(|entry| entry.path == "finder-hidden.txt" && entry.hidden) - ); + assert!(shown_entries + .iter() + .any(|entry| entry.path == "finder-hidden.txt" && entry.hidden)); let _ = fs::remove_dir_all(workdir); } @@ -5453,14 +5486,14 @@ mod tests { .expect("create hidden file"); let hidden_entries = list_test_entries(&workdir, Some(false)); - assert!(!hidden_entries.iter().any(|entry| entry.path == "windows-hidden.txt")); + assert!(!hidden_entries + .iter() + .any(|entry| entry.path == "windows-hidden.txt")); let shown_entries = list_test_entries(&workdir, Some(true)); - assert!( - shown_entries - .iter() - .any(|entry| entry.path == "windows-hidden.txt" && entry.hidden) - ); + assert!(shown_entries + .iter() + .any(|entry| entry.path == "windows-hidden.txt" && entry.hidden)); let _ = fs::remove_dir_all(workdir); } @@ -5567,8 +5600,7 @@ mod tests { fs::write(workdir.join(".gitignore"), "ignored_dir/\n").expect("write gitignore"); fs::write(workdir.join("visible.txt"), "visible").expect("write visible file"); fs::write(workdir.join(".hidden.txt"), "hidden").expect("write hidden file"); - fs::write(workdir.join("ignored_dir/child.txt"), "ignored") - .expect("write ignored file"); + fs::write(workdir.join("ignored_dir/child.txt"), "ignored").expect("write ignored file"); let shown_entries = mention_test_entries(&workdir, Some(true)); let entry = |path: &str| { diff --git a/crates/agent-gui/src-tauri/src/commands/workspace/git.rs b/crates/agent-gui/src-tauri/src/commands/workspace/git.rs index 5e93ac6b3..08f6fa184 100644 --- a/crates/agent-gui/src-tauri/src/commands/workspace/git.rs +++ b/crates/agent-gui/src-tauri/src/commands/workspace/git.rs @@ -3906,12 +3906,9 @@ mod tests { assert!(names.contains(&"rename-target"), "branches: {names:?}"); assert!(!names.contains(&"rename-source"), "branches: {names:?}"); - let renamed_current = git_rename_branch_sync( - workdir, - initial.head.clone(), - "renamed-current".to_string(), - ) - .expect("rename current branch"); + let renamed_current = + git_rename_branch_sync(workdir, initial.head.clone(), "renamed-current".to_string()) + .expect("rename current branch"); assert!( renamed_current.ok, "rename current failed: {}", @@ -3991,7 +3988,11 @@ mod tests { run_temp_git( repo.path(), - &["update-ref", "refs/remotes/origin/base", initial_sha.as_str()], + &[ + "update-ref", + "refs/remotes/origin/base", + initial_sha.as_str(), + ], ); let from_remote = git_create_branch_sync( workdir.clone(), @@ -4016,11 +4017,8 @@ mod tests { Some("no-such-ref".to_string()), ); assert!(bogus.is_err(), "bogus start point should error"); - let dashed = git_create_branch_sync( - workdir, - "from-dashed".to_string(), - Some("-d".to_string()), - ); + let dashed = + git_create_branch_sync(workdir, "from-dashed".to_string(), Some("-d".to_string())); assert!(dashed.is_err(), "dashed start point should error"); } diff --git a/crates/agent-gui/src-tauri/src/runtime/managed_process.rs b/crates/agent-gui/src-tauri/src/runtime/managed_process.rs index 600851aca..01b29cf0f 100644 --- a/crates/agent-gui/src-tauri/src/runtime/managed_process.rs +++ b/crates/agent-gui/src-tauri/src/runtime/managed_process.rs @@ -44,7 +44,10 @@ pub struct ManagedProcessNotifier { impl ManagedProcessNotifier { fn changed(&self, snapshot: &ManagedProcessSnapshot) { - if let Err(error) = self.app_handle.emit(MANAGED_PROCESS_CHANGED_EVENT, snapshot) { + if let Err(error) = self + .app_handle + .emit(MANAGED_PROCESS_CHANGED_EVENT, snapshot) + { eprintln!("emit {MANAGED_PROCESS_CHANGED_EVENT} failed: {error}"); } if let Some(gateway) = self.gateway.upgrade() { @@ -605,10 +608,7 @@ impl ManagedProcessRegistry { signal_process_tree_by_pid(entry.pid, true); } else { // Restored entry: no handle, terminate the group by pid. - terminate_process_tree_by_pid( - entry.pid, - Duration::from_millis(STOP_GRACE_MS), - ); + terminate_process_tree_by_pid(entry.pid, Duration::from_millis(STOP_GRACE_MS)); entry.exit_code = None; } entry.finished_at = Some(now_ms()); @@ -743,10 +743,7 @@ impl ManagedProcessRegistry { RecordProbe::Unknown => {} RecordProbe::AliveMatching if record.isolated => restored.push(record), RecordProbe::AliveMatching => { - terminate_process_tree_by_pid( - record.pid, - Duration::from_millis(STOP_GRACE_MS), - ); + terminate_process_tree_by_pid(record.pid, Duration::from_millis(STOP_GRACE_MS)); drop_ids.push(record.id); } RecordProbe::Gone => drop_ids.push(record.id), @@ -1190,9 +1187,7 @@ mod tests { // Next launch. let registry = ManagedProcessRegistry::with_journal_conn(open_conn()); - registry - .startup_reconcile() - .expect("reconcile should work"); + registry.startup_reconcile().expect("reconcile should work"); assert!( wait_until(|| !process_alive(plain_pid)), diff --git a/crates/agent-gui/src-tauri/src/runtime/managed_process_journal.rs b/crates/agent-gui/src-tauri/src/runtime/managed_process_journal.rs index c9ed2762c..ae85515b9 100644 --- a/crates/agent-gui/src-tauri/src/runtime/managed_process_journal.rs +++ b/crates/agent-gui/src-tauri/src/runtime/managed_process_journal.rs @@ -181,7 +181,9 @@ pub fn delete_non_isolated_rows( /// from the result and deleted (they cannot be reasoned about safely). pub fn read_rows(conn: &Connection) -> Result, String> { let mut stmt = conn - .prepare("SELECT process_id, payload_json, owner_pid, owner_started_at FROM managed_processes") + .prepare( + "SELECT process_id, payload_json, owner_pid, owner_started_at FROM managed_processes", + ) .map_err(|e| format!("读取 managed process journal 失败:{e}"))?; let raw_rows = stmt .query_map([], |row| { diff --git a/crates/agent-gui/src-tauri/src/runtime/shell_runner.rs b/crates/agent-gui/src-tauri/src/runtime/shell_runner.rs index ba375f6f6..f01278970 100644 --- a/crates/agent-gui/src-tauri/src/runtime/shell_runner.rs +++ b/crates/agent-gui/src-tauri/src/runtime/shell_runner.rs @@ -510,7 +510,10 @@ where for (key, value) in &system_proxy_envs { c.env(key, value); } - c.envs(envs.iter().map(|(key, value)| (key.as_str(), value.as_str()))); + c.envs( + envs.iter() + .map(|(key, value)| (key.as_str(), value.as_str())), + ); if candidate.augment_macos_path { maybe_augment_macos_path(&mut c); } diff --git a/crates/agent-gui/src-tauri/src/services/automation/db.rs b/crates/agent-gui/src-tauri/src/services/automation/db.rs index 2ae57e95a..fbff249d1 100644 --- a/crates/agent-gui/src-tauri/src/services/automation/db.rs +++ b/crates/agent-gui/src-tauri/src/services/automation/db.rs @@ -33,8 +33,7 @@ pub fn truncate_run_output(text: &str) -> String { pub fn open_automation_connection() -> Result { let db_path = crate::commands::settings::config_db_path()?; - let conn = - Connection::open(db_path).map_err(|e| format!("打开 automation 数据库失败:{e}"))?; + let conn = Connection::open(db_path).map_err(|e| format!("打开 automation 数据库失败:{e}"))?; conn.busy_timeout(Duration::from_secs(5)) .map_err(|e| format!("设置 SQLite busy_timeout 失败:{e}"))?; conn.pragma_update(None, "journal_mode", "WAL") diff --git a/crates/agent-gui/src-tauri/src/services/automation/scheduler.rs b/crates/agent-gui/src-tauri/src/services/automation/scheduler.rs index 6342fdfbe..fe64cd143 100644 --- a/crates/agent-gui/src-tauri/src/services/automation/scheduler.rs +++ b/crates/agent-gui/src-tauri/src/services/automation/scheduler.rs @@ -73,9 +73,10 @@ impl AutomationScheduler { async fn run_loop(self: Arc) { { let store = Arc::clone(&self.store); - let recovered = - tauri::async_runtime::spawn_blocking(move || store.recover_interrupted_prompt_runs()) - .await; + let recovered = tauri::async_runtime::spawn_blocking(move || { + store.recover_interrupted_prompt_runs() + }) + .await; match recovered { Ok(Ok(count)) if count > 0 => { eprintln!("automation: expired {count} prompt run(s) interrupted by restart"); @@ -294,12 +295,7 @@ impl AutomationScheduler { Ok(CronRunNowResponse { started_at }) } - fn start_fire( - self: &Arc, - task: CronTask, - workdir: String, - trigger: RunTrigger, - ) -> bool { + fn start_fire(self: &Arc, task: CronTask, workdir: String, trigger: RunTrigger) -> bool { { let mut active = match self.active_runs.lock() { Ok(guard) => guard, @@ -317,12 +313,7 @@ impl AutomationScheduler { true } - async fn execute_fire( - self: Arc, - task: CronTask, - workdir: String, - trigger: RunTrigger, - ) { + async fn execute_fire(self: Arc, task: CronTask, workdir: String, trigger: RunTrigger) { let task_id = task.id.clone(); if trigger == RunTrigger::Scheduled { @@ -363,23 +354,20 @@ impl AutomationScheduler { // directory that no longer exists. Follow-global tasks keep the // legacy behavior (bash/prompt fail the run without disabling). let workdir = match task.workdir.as_deref().map(str::trim) { - Some(pin) if !pin.is_empty() => { - match resolve_workdir(Some(pin.to_string())) { - Ok(resolved) => resolved.display().to_string(), - Err(error) => { - let message = - format!("Cron task workspace is unavailable ({pin}): {error}"); - self.record_run_detached(failed_run( - &task_id, - message.clone(), - trigger.counted(), - )); - self.disable_task_detached(&task_id, message); - self.clear_active(&task_id); - return; - } + Some(pin) if !pin.is_empty() => match resolve_workdir(Some(pin.to_string())) { + Ok(resolved) => resolved.display().to_string(), + Err(error) => { + let message = format!("Cron task workspace is unavailable ({pin}): {error}"); + self.record_run_detached(failed_run( + &task_id, + message.clone(), + trigger.counted(), + )); + self.disable_task_detached(&task_id, message); + self.clear_active(&task_id); + return; } - } + }, _ => workdir, }; @@ -387,11 +375,10 @@ impl AutomationScheduler { let store = Arc::clone(&self.store); let queue_task = task.clone(); let queue_workdir = workdir.clone(); - let result = - tauri::async_runtime::spawn_blocking(move || { - store.queue_prompt_run(&queue_task, &queue_workdir, trigger.counted()) - }) - .await; + let result = tauri::async_runtime::spawn_blocking(move || { + store.queue_prompt_run(&queue_task, &queue_workdir, trigger.counted()) + }) + .await; match result { Ok(Ok(PromptQueueOutcome::Queued)) => {} Ok(Ok(PromptQueueOutcome::SkippedActiveRun)) => { @@ -418,14 +405,14 @@ impl AutomationScheduler { run.counted = trigger.counted(); run }) - .await - .unwrap_or_else(|error| { - failed_run( - &task_id, - format!("Cron task execution join failed: {error}"), - false, - ) - }); + .await + .unwrap_or_else(|error| { + failed_run( + &task_id, + format!("Cron task execution join failed: {error}"), + false, + ) + }); self.record_run_detached(run); self.clear_active(&task_id); } @@ -505,9 +492,18 @@ fn execute_blocking(task: CronTask, workdir: String) -> CompletedRun { fn execute_bash(task: &CronTask, workdir: String) -> CompletedRun { let started_at = now_ms(); let overall = Instant::now(); - let script = task.script.as_deref().unwrap_or_default().trim().to_string(); + let script = task + .script + .as_deref() + .unwrap_or_default() + .trim() + .to_string(); if script.is_empty() { - return failed_run(&task.id, "No Bash script configured for this Cron task.".to_string(), true); + return failed_run( + &task.id, + "No Bash script configured for this Cron task.".to_string(), + true, + ); } if workdir.trim().is_empty() { return failed_run( @@ -549,7 +545,11 @@ fn execute_http(task: &CronTask) -> CompletedRun { let overall = Instant::now(); let requests = task.requests.clone().unwrap_or_default(); if requests.is_empty() { - return failed_run(&task.id, "No HTTP requests configured for this Cron task.".to_string(), true); + return failed_run( + &task.id, + "No HTTP requests configured for this Cron task.".to_string(), + true, + ); } let client = match build_http_client(Some(task.timeout_seconds.saturating_mul(1_000))) { Ok(client) => client, @@ -559,7 +559,11 @@ fn execute_http(task: &CronTask) -> CompletedRun { let mut sections = Vec::new(); let mut success = true; for (index, request) in requests.into_iter().enumerate() { - let display = format!("{} {}", request.method.trim().to_uppercase(), request.url.trim()); + let display = format!( + "{} {}", + request.method.trim().to_uppercase(), + request.url.trim() + ); match run_single_http_request(&client, to_http_input(request)) { Ok(result) => sections.push(format_http_result(index + 1, &display, &result)), Err(error) => { diff --git a/crates/agent-gui/src-tauri/src/services/automation/store.rs b/crates/agent-gui/src-tauri/src/services/automation/store.rs index 995a13f28..101dc0620 100644 --- a/crates/agent-gui/src-tauri/src/services/automation/store.rs +++ b/crates/agent-gui/src-tauri/src/services/automation/store.rs @@ -315,18 +315,24 @@ impl AutomationStore { workdir: &str, counted: bool, ) -> Result { - let prompt = task.prompt.as_deref().unwrap_or_default().trim().to_string(); + let prompt = task + .prompt + .as_deref() + .unwrap_or_default() + .trim() + .to_string(); if prompt.is_empty() { return Err("Auto Prompt task has no prompt content.".to_string()); } - let selected_model = task - .selected_model - .as_ref() - .ok_or_else(|| "Auto Prompt task is missing the selected model configuration.".to_string())?; + let selected_model = task.selected_model.as_ref().ok_or_else(|| { + "Auto Prompt task is missing the selected model configuration.".to_string() + })?; let provider_id = selected_model.custom_provider_id.trim().to_string(); let model = selected_model.model.trim().to_string(); if provider_id.is_empty() || model.is_empty() { - return Err("Auto Prompt task has an invalid selected model configuration.".to_string()); + return Err( + "Auto Prompt task has an invalid selected model configuration.".to_string(), + ); } let request = { @@ -826,10 +832,7 @@ fn ensure_id(item: &mut Value) { .unwrap_or_default() .is_empty(); if missing { - map.insert( - "id".to_string(), - Value::String(Uuid::new_v4().to_string()), - ); + map.insert("id".to_string(), Value::String(Uuid::new_v4().to_string())); } } diff --git a/crates/agent-gui/src-tauri/src/services/automation/tests.rs b/crates/agent-gui/src-tauri/src/services/automation/tests.rs index 6986d9169..5bf12ee5f 100644 --- a/crates/agent-gui/src-tauri/src/services/automation/tests.rs +++ b/crates/agent-gui/src-tauri/src/services/automation/tests.rs @@ -138,10 +138,7 @@ fn cron_apply_reorder_requires_full_permutation() { let response = store .cron_apply(apply_input( base, - vec![ - create_bash_task_op("a", "A"), - create_bash_task_op("b", "B"), - ], + vec![create_bash_task_op("a", "A"), create_bash_task_op("b", "B")], )) .expect("seed"); @@ -214,7 +211,9 @@ fn prompt_run_lifecycle_queue_claim_complete() { let (store, task) = store_with_task(create_prompt_task_op("p1")); assert!(matches!( - store.queue_prompt_run(&task, "", true).expect("queue prompt run"), + store + .queue_prompt_run(&task, "", true) + .expect("queue prompt run"), super::store::PromptQueueOutcome::Queued )); @@ -224,7 +223,9 @@ fn prompt_run_lifecycle_queue_claim_complete() { // Second fire while pending is skipped. assert!(matches!( - store.queue_prompt_run(&task, "", true).expect("second queue"), + store + .queue_prompt_run(&task, "", true) + .expect("second queue"), super::store::PromptQueueOutcome::SkippedActiveRun )); @@ -402,9 +403,7 @@ fn released_prompt_run_returns_to_pending() { fn recover_marks_interrupted_runs_expired() { let (store, task) = store_with_task(create_prompt_task_op("p1")); store.queue_prompt_run(&task, "", true).expect("queue"); - let recovered = store - .recover_interrupted_prompt_runs() - .expect("recover"); + let recovered = store.recover_interrupted_prompt_runs().expect("recover"); assert_eq!(recovered, 1); let runs = store.list_runs("p1", 10).expect("list runs"); @@ -488,8 +487,14 @@ fn masked_headers_round_trip_keeps_stored_secret() { let task = &masked.cron.tasks[0]; let headers = task.requests.as_ref().unwrap()[0].headers.as_ref().unwrap(); - assert_eq!(headers.get("Authorization").map(String::as_str), Some("Bearer secret-token")); - assert_eq!(task.requests.as_ref().unwrap()[0].url, "https://example.com/hook-v2"); + assert_eq!( + headers.get("Authorization").map(String::as_str), + Some("Bearer secret-token") + ); + assert_eq!( + task.requests.as_ref().unwrap()[0].url, + "https://example.com/hook-v2" + ); } #[test] @@ -838,7 +843,9 @@ fn queue_prompt_run_lease_uses_task_timeout() { let task = response.cron.tasks[0].clone(); assert!(matches!( - store.queue_prompt_run(&task, "", true).expect("queue prompt run"), + store + .queue_prompt_run(&task, "", true) + .expect("queue prompt run"), super::store::PromptQueueOutcome::Queued )); let claims = store.claim_prompt_runs().expect("claim"); diff --git a/crates/agent-gui/src-tauri/src/services/automation/validate.rs b/crates/agent-gui/src-tauri/src/services/automation/validate.rs index 7a4a4f014..a285b7c06 100644 --- a/crates/agent-gui/src-tauri/src/services/automation/validate.rs +++ b/crates/agent-gui/src-tauri/src/services/automation/validate.rs @@ -192,15 +192,16 @@ fn parse_headers( } } -fn parse_selected_model( - value: Option<&Value>, - label: &str, -) -> Result { +fn parse_selected_model(value: Option<&Value>, label: &str) -> Result { let map = value .and_then(Value::as_object) .ok_or_else(|| format!("{label}.selectedModel 不能为空"))?; Ok(SelectedModelRef { - custom_provider_id: required_string(map, "customProviderId", &format!("{label}.selectedModel"))?, + custom_provider_id: required_string( + map, + "customProviderId", + &format!("{label}.selectedModel"), + )?, model: required_string(map, "model", &format!("{label}.selectedModel"))?, }) } @@ -344,9 +345,12 @@ pub fn validate_hook(value: Value, label: &str) -> Result { /// Merge a partial patch onto a stored item (top-level keys), returning the /// merged object for re-validation. -pub fn merge_patch(stored: &impl serde::Serialize, patch: Value, label: &str) -> Result { - let base = serde_json::to_value(stored) - .map_err(|e| format!("{label} 序列化失败:{e}"))?; +pub fn merge_patch( + stored: &impl serde::Serialize, + patch: Value, + label: &str, +) -> Result { + let base = serde_json::to_value(stored).map_err(|e| format!("{label} 序列化失败:{e}"))?; let mut merged = expect_object(base, label)?; let patch = expect_object(patch, &format!("{label}.patch"))?; for (key, value) in patch { @@ -360,10 +364,7 @@ pub fn merge_patch(stored: &impl serde::Serialize, patch: Value, label: &str) -> /// Replace masked header values in `incoming` with the currently stored values /// so remote clients can round-trip requests without seeing secrets. -pub fn restore_masked_headers( - incoming: &mut Value, - stored_requests: Option<&[HttpRequestSpec]>, -) { +pub fn restore_masked_headers(incoming: &mut Value, stored_requests: Option<&[HttpRequestSpec]>) { let Some(requests) = incoming.get_mut("requests").and_then(Value::as_array_mut) else { return; }; diff --git a/crates/agent-gui/src-tauri/src/services/gateway/connection.rs b/crates/agent-gui/src-tauri/src/services/gateway/connection.rs index fc48a2a83..bbbd8aa29 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway/connection.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway/connection.rs @@ -3,15 +3,12 @@ use std::sync::{Arc, Once}; use std::time::{Duration, Instant}; use futures_util::SinkExt as _; -use reqwest::Url; use serde_json::Value; use tauri::Emitter; use tokio::sync::{mpsc, watch}; use tokio_stream::wrappers::ReceiverStream; use tokio_stream::StreamExt as _; use tokio_tungstenite::tungstenite::Message as WsMessage; -use tonic::metadata::MetadataValue; -use tonic::transport::{ClientTlsConfig, Endpoint}; use crate::commands::settings::RemoteSettingsPayload; use crate::runtime::terminal::TerminalEventPayload; @@ -20,14 +17,7 @@ use crate::services::gateway_bridge; use super::gateway_proto::v2; use super::*; -/// v2 链路一次连接尝试的失败分类(模块内部)。 -enum WsServeError { - /// 握手层失败(旧网关无 /ws/v2/agent):允许同一次尝试内回退 gRPC。 - Handshake(String), - /// 鉴权拒绝或链路已建立后出错:不回退,按普通连接错误上抛;下次重连仍先试 v2。 - Fatal(String), -} - +/// 后台任务句柄的 RAII 中止器。 struct AbortTaskOnDrop(tauri::async_runtime::JoinHandle<()>); impl Drop for AbortTaskOnDrop { @@ -105,72 +95,33 @@ impl GatewayController { } } - /// 连接尝试入口:先试 v2;握手层失败(旧网关无 /ws/v2/agent,典型 404/非 101)时同一次尝试内 - /// 回退 v1 gRPC,鉴权被拒不回退直接上抛;每次重连都重新从 v2 试起。 - /// 回退会话最多持续 [`GATEWAY_WS_UPGRADE_RETRY_INTERVAL`] 便主动断开重试 v2——防瞬时抖动 - /// 导致长驻 v1、污染"v1 流量归零"信号(重连亚秒级且会话层幂等对账,中断代价可忽略)。 + /// v2 主链路(v1 gRPC 回退已随 v1 协议移除):hello 握手完成鉴权与会话登记后双向收发 + /// 信封(双通道合并、状态迁移、对账、分发),外加传输层存活看门狗。任何失败(网关不可达、 + /// 握手失败、鉴权被拒、链路中断)一律上抛错误消息,由外层 run 循环统一退避重连。 pub(crate) async fn connect_and_serve( self: &Arc, config: RemoteSettingsPayload, config_rx: &mut watch::Receiver, ) -> Result<(), String> { - match self.connect_and_serve_ws(config.clone(), config_rx).await { - Ok(()) => Ok(()), - Err(WsServeError::Fatal(error)) => Err(error), - Err(WsServeError::Handshake(error)) => { - eprintln!("gateway v2 handshake failed: {error}; falling back to v1 gRPC"); - #[allow(deprecated)] - { - tokio::select! { - result = self.connect_and_serve_grpc(config, config_rx) => result, - _ = tokio::time::sleep(GATEWAY_WS_UPGRADE_RETRY_INTERVAL) => { - eprintln!( - "gateway v1 fallback session reached upgrade-retry window; \ - reconnecting to retry v2" - ); - Ok(()) - } - } - } - } - } - } - - /// v2 主链路:hello 握手(等价 Authenticate)后按 gRPC AgentConnect 完全相同的语义收发信封 - /// (双通道合并、状态迁移、对账、分发),外加取代 h2 keepalive 的存活看门狗。 - async fn connect_and_serve_ws( - self: &Arc, - config: RemoteSettingsPayload, - config_rx: &mut watch::Receiver, - ) -> Result<(), WsServeError> { - let ws_url = build_ws_url(&config.gateway_url, config.grpc_port, GATEWAY_WS_AGENT_PATH) - .map_err(WsServeError::Handshake)?; + let ws_url = build_ws_url(&config.gateway_url, config.grpc_port, GATEWAY_WS_AGENT_PATH)?; let hello = build_client_hello( &config.token, effective_agent_id(&config), crate::app_version().to_string(), ); - let connect_result = - await_abortable_on_reconfigure(&config, config_rx, async move { - Ok(connect_agent_ws(&ws_url, hello).await) - }) - .await - .map_err(WsServeError::Fatal)?; + let connect_result = await_abortable_on_reconfigure(&config, config_rx, async move { + Ok(connect_agent_ws(&ws_url, hello).await) + }) + .await?; let (mut ws, server_hello) = match connect_result { None => return Ok(()), - Some(Ok(established)) => established, - Some(Err(WsHandshakeError::Handshake(error))) => { - return Err(WsServeError::Handshake(error)) - } - Some(Err(WsHandshakeError::AuthRejected(error))) => { - return Err(WsServeError::Fatal(error)) - } + Some(established) => established?, }; let (outbound_tx, outbound_rx) = mpsc::channel::(4096); self.set_outbound_sender(Some(outbound_tx)); - // 控制小通道:Pong 不排在数据信封之后(与 gRPC 路径同策略,下方公平合并进出站流)。 + // 控制小通道:Pong 不排在数据信封之后(下方公平合并进出站流)。 let (outbound_control_tx, outbound_control_rx) = mpsc::channel::(GATEWAY_OUTBOUND_CONTROL_QUEUE_DEPTH); self.set_outbound_control_sender(Some(outbound_control_tx)); @@ -292,139 +243,6 @@ impl GatewayController { } .await; - let _ = terminal_stop_tx.send(true); - terminal_task.abort(); - self.set_terminal_stream_sender(None); - serve_result.map_err(WsServeError::Fatal) - } - - /// v1 gRPC 主链路(Authenticate + AgentConnect),行为与迁移前逐字一致。 - #[deprecated(note = "v1 gRPC 链路仅作旧网关回退,v1 移除时一并删除")] - #[allow(deprecated)] // 函数体即回退路径,成串调用同批弃用的 gRPC 辅助函数。 - pub(crate) async fn connect_and_serve_grpc( - self: &Arc, - config: RemoteSettingsPayload, - config_rx: &mut watch::Receiver, - ) -> Result<(), String> { - let grpc_url = build_grpc_url(&config)?; - // The heartbeat interval setting drives the h2 keepalive cadence; the - // lower bound stays above the gateway's keepalive enforcement MinTime. - let keepalive_interval = Duration::from_secs(config.heartbeat_interval.clamp(10, 60)); - let endpoint = build_endpoint(&grpc_url, keepalive_interval)?; - let channel = endpoint.connect_lazy(); - - let mut client = proto::agent_gateway_client::AgentGatewayClient::new(channel) - .max_decoding_message_size(GATEWAY_GRPC_MAX_MESSAGE_BYTES) - .max_encoding_message_size(GATEWAY_GRPC_MAX_MESSAGE_BYTES); - let mut auth_request = tonic::Request::new(proto::AuthRequest { - token: config.token.clone(), - agent_id: effective_agent_id(&config), - agent_version: crate::app_version().to_string(), - }); - insert_bearer_metadata(auth_request.metadata_mut(), &config.token)?; - - let auth_call = client.authenticate(auth_request); - let auth_response = match await_abortable_on_reconfigure(&config, config_rx, async move { - tokio::time::timeout(Duration::from_secs(10), auth_call) - .await - .map_err(|_| "gateway authenticate timed out".to_string())? - .map_err(|e| format!("gateway authenticate failed: {e}")) - .map(|response| response.into_inner()) - }) - .await? - { - Some(response) => response, - None => return Ok(()), - }; - if !auth_response.success { - return Err(if auth_response.message.trim().is_empty() { - "gateway authentication failed".to_string() - } else { - auth_response.message - }); - } - - let terminal_client = client.clone(); - - let (outbound_tx, outbound_rx) = mpsc::channel::(4096); - self.set_outbound_sender(Some(outbound_tx)); - // Control lane: Pong replies must not queue behind streamed data - // envelopes, or wake probes time out precisely when a reply is - // saturating the data queue. Merged fairly into the same stream below. - let (outbound_control_tx, outbound_control_rx) = - mpsc::channel::(GATEWAY_OUTBOUND_CONTROL_QUEUE_DEPTH); - self.set_outbound_control_sender(Some(outbound_control_tx)); - let (terminal_stop_tx, terminal_stop_rx) = watch::channel(false); - let terminal_task = - self.spawn_terminal_stream(terminal_client, config.clone(), terminal_stop_rx); - - let serve_result = async { - let mut connect_request = tonic::Request::new( - ReceiverStream::new(outbound_control_rx).merge(ReceiverStream::new(outbound_rx)), - ); - insert_bearer_metadata(connect_request.metadata_mut(), &config.token)?; - - let connect_call = client.agent_connect(connect_request); - let response = match await_abortable_on_reconfigure(&config, config_rx, async move { - tokio::time::timeout(Duration::from_secs(10), connect_call) - .await - .map_err(|_| "open gateway stream timed out".to_string())? - .map_err(|e| format!("open gateway stream failed: {e}")) - }) - .await? - { - Some(response) => response, - None => return Ok(()), - }; - let mut inbound = response.into_inner(); - - let connected_at = now_unix_seconds(); - self.publish_status(|status| { - status.online = true; - status.enabled = true; - status.configured = true; - status.gateway_url = config.gateway_url.clone(); - status.agent_id = effective_agent_id(&config); - status.session_id = Some(auth_response.session_id.clone()); - status.connected_since = Some(connected_at); - status.last_heartbeat = Some(connected_at); - status.last_error = None; - status.protocol = Some("v1".to_string()); - }); - - let _reconcile_task = AbortTaskOnDrop(self.spawn_post_connect_reconciliation()); - - // Dead links are detected by the transport-level HTTP/2 keepalive - // configured on the endpoint and surface as receive errors here. - let receive_result = loop { - tokio::select! { - changed = config_rx.changed() => { - if changed.is_err() { - break Ok(()); - } - let next = config_rx.borrow().clone(); - if next != config { - break Ok(()); - } - } - message = inbound.message() => { - match message { - Err(err) => break Err(format!("gateway stream receive failed: {err}")), - Ok(None) => break Err("gateway stream closed".to_string()), - Ok(Some(envelope)) => { - self.touch_heartbeat(); - if let Err(error) = self.handle_gateway_envelope(envelope).await { - break Err(error); - } - } - } - } - } - }; - receive_result - } - .await; - let _ = terminal_stop_tx.send(true); terminal_task.abort(); self.set_terminal_stream_sender(None); @@ -700,82 +518,6 @@ pub(crate) fn build_error_response_envelope( } } -#[deprecated(note = "v1 gRPC 链路仅作旧网关回退,v1 移除时一并删除")] -pub(crate) fn build_grpc_url(config: &RemoteSettingsPayload) -> Result { - let grpc_endpoint = config.grpc_endpoint.trim(); - if !grpc_endpoint.is_empty() { - let with_scheme = - if grpc_endpoint.starts_with("http://") || grpc_endpoint.starts_with("https://") { - grpc_endpoint.to_string() - } else { - format!("http://{grpc_endpoint}") - }; - let mut url = - Url::parse(&with_scheme).map_err(|e| format!("invalid gateway gRPC endpoint: {e}"))?; - if url.scheme() != "http" && url.scheme() != "https" { - return Err("gateway gRPC endpoint must start with http:// or https://".to_string()); - } - url.set_path(""); - url.set_query(None); - url.set_fragment(None); - return Ok(url.to_string().trim_end_matches('/').to_string()); - } - - let trimmed = config.gateway_url.trim(); - if trimmed.is_empty() { - return Err("gateway URL is empty".to_string()); - } - - let mut url = Url::parse(trimmed).map_err(|e| format!("invalid gateway URL: {e}"))?; - if url.scheme() != "http" && url.scheme() != "https" { - return Err("gateway URL must start with http:// or https://".to_string()); - } - url.set_port(Some(config.grpc_port)) - .map_err(|_| "failed to apply gRPC port to gateway URL".to_string())?; - url.set_path(""); - url.set_query(None); - url.set_fragment(None); - Ok(url.to_string().trim_end_matches('/').to_string()) -} - -pub(crate) fn is_h2_protocol_error(message: &str) -> bool { - let normalized = message.to_ascii_lowercase(); - normalized.contains("h2 protocol error") || normalized.contains("http2 error") -} - -#[deprecated(note = "v1 gRPC 链路仅作旧网关回退,v1 移除时一并删除")] -pub(crate) fn build_endpoint( - grpc_url: &str, - keepalive_interval: Duration, -) -> Result { - // HTTP/2 keepalive owns dead-link detection: PING frames bypass stream - // flow control, so congestion or streaming never delays them. - let endpoint = Endpoint::from_shared(grpc_url.to_string()) - .map_err(|e| format!("invalid gateway endpoint: {e}"))? - .connect_timeout(Duration::from_secs(10)) - .tcp_keepalive(Some(Duration::from_secs(30))) - .http2_keep_alive_interval(keepalive_interval) - .keep_alive_timeout(Duration::from_secs(15)) - .keep_alive_while_idle(true); - - if grpc_url.starts_with("https://") { - ensure_rustls_crypto_provider(); - let host = Url::parse(grpc_url) - .ok() - .and_then(|url| url.host_str().map(ToString::to_string)) - .ok_or_else(|| "failed to extract TLS host from gateway URL".to_string())?; - endpoint - .tls_config( - ClientTlsConfig::new() - .with_enabled_roots() - .domain_name(host), - ) - .map_err(|e| format!("configure gateway TLS failed: {e}")) - } else { - Ok(endpoint) - } -} - pub(crate) fn ensure_rustls_crypto_provider() { static INSTALL_DEFAULT_PROVIDER: Once = Once::new(); INSTALL_DEFAULT_PROVIDER.call_once(|| { @@ -783,17 +525,6 @@ pub(crate) fn ensure_rustls_crypto_provider() { }); } -#[deprecated(note = "v1 gRPC 链路仅作旧网关回退,v1 移除时一并删除")] -pub(crate) fn insert_bearer_metadata( - metadata: &mut tonic::metadata::MetadataMap, - token: &str, -) -> Result<(), String> { - let value = MetadataValue::try_from(format!("Bearer {}", token.trim())) - .map_err(|e| format!("invalid gateway authorization metadata: {e}"))?; - metadata.insert("authorization", value); - Ok(()) -} - pub(crate) fn is_remote_configured(config: &RemoteSettingsPayload) -> bool { !config.gateway_url.trim().is_empty() && !config.token.trim().is_empty() } diff --git a/crates/agent-gui/src-tauri/src/services/gateway/managed_process.rs b/crates/agent-gui/src-tauri/src/services/gateway/managed_process.rs index 5dfca610c..c2dd993d1 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway/managed_process.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway/managed_process.rs @@ -119,8 +119,7 @@ impl GatewayController { let process_id = request.process_id.trim().to_string(); let process_id = (!process_id.is_empty()).then_some(process_id); let snapshot = - run_registry_task(registry, move |registry| registry.clear(process_id)) - .await?; + run_registry_task(registry, move |registry| registry.clear(process_id)).await?; Ok(proto::ManagedProcessResponse { action, snapshot: Some(build_managed_process_snapshot_proto(&snapshot)), diff --git a/crates/agent-gui/src-tauri/src/services/gateway/mod.rs b/crates/agent-gui/src-tauri/src/services/gateway/mod.rs index 249157b93..58a13c2af 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway/mod.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway/mod.rs @@ -30,14 +30,17 @@ use crate::services::tunnel::{TunnelProxy, TunnelStore}; use crate::services::workspace_watch::WorkspaceWatchService; /// 网关 proto 生成模块。v2 帧壳经 prost `super::v1::` 路径复用 v1 业务消息,两版本必须并列嵌套。 +/// (纯消息生成,无 gRPC 服务;直接 include OUT_DIR 产物,不再依赖运行时 tonic。) pub mod gateway_proto { + // v1 整包生成;AuthRequest/AuthResponse 等历史消息按 proto 纪律保留但不再构造:抑制 dead_code。 + #[allow(dead_code)] pub mod v1 { - tonic::include_proto!("liveagent.gateway.v1"); + include!(concat!(env!("OUT_DIR"), "/liveagent.gateway.v1.rs")); } // v2 整包生成,浏览器链路专用帧类型桌面端不构造:抑制 dead_code。 #[allow(dead_code)] pub mod v2 { - tonic::include_proto!("liveagent.gateway.v2"); + include!(concat!(env!("OUT_DIR"), "/liveagent.gateway.v2.rs")); } } @@ -80,11 +83,10 @@ pub(crate) const UI_ONLY_SETTINGS_SYNC_FIELDS: &[&str] = &[ "theme", "locale", ]; -pub(crate) const GATEWAY_GRPC_MAX_MESSAGE_BYTES: usize = 64 * 1024 * 1024; // Small dedicated lane for latency-sensitive control replies (Pongs). It is -// merged into the same gRPC outbound stream but never sits behind thousands -// of queued data envelopes, so wake probes stay answerable while a reply is -// streaming tokens through the saturated data queue. +// merged into the same outbound envelope stream but never sits behind +// thousands of queued data envelopes, so wake probes stay answerable while a +// reply is streaming tokens through the saturated data queue. pub(crate) const GATEWAY_OUTBOUND_CONTROL_QUEUE_DEPTH: usize = 64; pub(crate) const GATEWAY_RECONNECT_MIN: Duration = Duration::from_millis(250); pub(crate) const GATEWAY_RECONNECT_MAX: Duration = Duration::from_secs(5); @@ -93,8 +95,6 @@ pub(crate) const GATEWAY_RECONNECT_STABLE_AFTER: Duration = Duration::from_secs( // 以及静默超 3×心跳周期发 WS Ping 探活后的宽限时长。 pub(crate) const GATEWAY_WS_DEFAULT_HEARTBEAT_PERIOD: Duration = Duration::from_secs(30); pub(crate) const GATEWAY_WS_PROBE_GRACE: Duration = Duration::from_secs(10); -// v1 回退会话的升级重试窗口:回退可能只是瞬时抖动,到点主动重拨让 v2 恢复,避免长驻弃用链路。 -pub(crate) const GATEWAY_WS_UPGRADE_RETRY_INTERVAL: Duration = Duration::from_secs(10 * 60); pub(crate) const GATEWAY_POST_CONNECT_REPLAY_DELAY: Duration = Duration::from_millis(200); pub(crate) const GATEWAY_TERMINAL_STREAM_RECONNECT_MIN: Duration = Duration::from_millis(250); pub(crate) const GATEWAY_TERMINAL_STREAM_RECONNECT_MAX: Duration = Duration::from_secs(5); diff --git a/crates/agent-gui/src-tauri/src/services/gateway/terminal.rs b/crates/agent-gui/src-tauri/src/services/gateway/terminal.rs index f5639f37c..dc68c927d 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway/terminal.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway/terminal.rs @@ -3,7 +3,6 @@ use std::time::Instant; use futures_util::SinkExt as _; use tokio::sync::{mpsc, watch}; -use tokio_stream::wrappers::ReceiverStream; use tokio_stream::StreamExt as _; use tokio_tungstenite::tungstenite::Message as WsMessage; use uuid::Uuid; @@ -23,7 +22,7 @@ use super::*; impl GatewayController { /// v2 终端数据面(/ws/v2/terminal,角色 AGENT):PTY/注册表侧播种口与 gRPC 版完全一致; - /// 仅当主链路运行在 v2 上时由 connect_and_serve_ws 生成本任务,传输选择随主链路贯通。 + /// 由 connect_and_serve 在主链路建立后生成本任务,与主链路同生命周期。 pub(crate) fn spawn_terminal_stream_ws( self: &Arc, config: RemoteSettingsPayload, @@ -90,7 +89,11 @@ impl GatewayController { config: RemoteSettingsPayload, mut stop_rx: watch::Receiver, ) -> Result<(), String> { - let ws_url = build_ws_url(&config.gateway_url, config.grpc_port, GATEWAY_WS_TERMINAL_PATH)?; + let ws_url = build_ws_url( + &config.gateway_url, + config.grpc_port, + GATEWAY_WS_TERMINAL_PATH, + )?; let hello = build_client_hello( &config.token, effective_agent_id(&config), @@ -169,135 +172,6 @@ impl GatewayController { result } - /// v1 gRPC 终端流入口,行为与迁移前逐字一致。 - #[deprecated(note = "v1 gRPC 链路仅作旧网关回退,v1 移除时一并删除")] - #[allow(deprecated)] // 函数体即回退路径,成串调用同批弃用的 gRPC 辅助函数。 - pub(crate) fn spawn_terminal_stream( - self: &Arc, - client: proto::agent_gateway_client::AgentGatewayClient, - config: RemoteSettingsPayload, - stop_rx: watch::Receiver, - ) -> tauri::async_runtime::JoinHandle<()> { - let controller = Arc::clone(self); - tauri::async_runtime::spawn(async move { - controller - .run_terminal_stream(client, config, stop_rx) - .await; - }) - } - - #[deprecated(note = "v1 gRPC 链路仅作旧网关回退,v1 移除时一并删除")] - #[allow(deprecated)] // 函数体即回退路径,成串调用同批弃用的 gRPC 辅助函数。 - pub(crate) async fn run_terminal_stream( - self: Arc, - client: proto::agent_gateway_client::AgentGatewayClient, - config: RemoteSettingsPayload, - mut stop_rx: watch::Receiver, - ) { - let mut reconnect_delay = GATEWAY_TERMINAL_STREAM_RECONNECT_MIN; - - loop { - if *stop_rx.borrow() { - break; - } - - let attempt_started = Instant::now(); - let result = Arc::clone(&self) - .run_terminal_stream_once(client.clone(), config.clone(), stop_rx.clone()) - .await; - if *stop_rx.borrow() { - break; - } - self.set_terminal_stream_sender(None); - - if attempt_started.elapsed() >= GATEWAY_TERMINAL_STREAM_STABLE_AFTER { - reconnect_delay = GATEWAY_TERMINAL_STREAM_RECONNECT_MIN; - } - match result { - Ok(()) => eprintln!("gateway terminal stream closed; reconnecting"), - Err(error) => eprintln!("gateway terminal stream stopped: {error}; reconnecting"), - } - - let delay = reconnect_delay; - reconnect_delay = - std::cmp::min(reconnect_delay * 2, GATEWAY_TERMINAL_STREAM_RECONNECT_MAX); - tokio::select! { - changed = stop_rx.changed() => { - if changed.is_err() || *stop_rx.borrow() { - break; - } - } - _ = tokio::time::sleep(delay) => {} - } - } - - self.set_terminal_stream_sender(None); - } - - #[deprecated(note = "v1 gRPC 链路仅作旧网关回退,v1 移除时一并删除")] - #[allow(deprecated)] // 函数体即回退路径,成串调用同批弃用的 gRPC 辅助函数。 - pub(crate) async fn run_terminal_stream_once( - self: Arc, - mut client: proto::agent_gateway_client::AgentGatewayClient, - config: RemoteSettingsPayload, - mut stop_rx: watch::Receiver, - ) -> Result<(), String> { - let (terminal_tx, terminal_rx) = mpsc::channel::(4096); - - let result = async { - queue_terminal_stream_handshake_frame(&terminal_tx)?; - let mut request = tonic::Request::new(ReceiverStream::new(terminal_rx)); - insert_bearer_metadata(request.metadata_mut(), &config.token)?; - let response = tokio::select! { - changed = stop_rx.changed() => { - if changed.is_err() || *stop_rx.borrow() { - return Ok(()); - } - return Ok(()); - } - response = client.agent_terminal_connect(request) => { - response.map_err(|error| { - format_gateway_terminal_stream_rpc_error("open", &error, &config) - })? - } - }; - self.set_terminal_stream_sender(Some(terminal_tx.clone())); - let mut inbound = response.into_inner(); - let mut keepalive = tokio::time::interval(GATEWAY_TERMINAL_STREAM_KEEPALIVE_INTERVAL); - keepalive.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); - keepalive.tick().await; - loop { - tokio::select! { - changed = stop_rx.changed() => { - if changed.is_err() || *stop_rx.borrow() { - return Ok(()); - } - } - _ = keepalive.tick() => { - queue_terminal_stream_keepalive_frame(&terminal_tx).await?; - } - message = inbound.message() => { - match message { - Ok(Some(frame)) => { - if let Err(error) = self.handle_terminal_stream_frame(frame).await { - eprintln!("handle gateway terminal stream frame failed: {error}"); - } - } - Ok(None) => return Ok(()), - Err(error) => { - return Err(format_gateway_terminal_stream_rpc_error("receive", &error, &config)) - } - } - } - } - } - } - .await; - - self.clear_terminal_stream_sender_if_current(&terminal_tx); - result - } - pub(crate) async fn handle_terminal_stream_frame( &self, frame: proto::TerminalStreamFrame, @@ -961,53 +835,3 @@ pub(crate) fn build_terminal_event_envelope(payload: TerminalEventPayload) -> pr )), } } - -pub(crate) fn queue_terminal_stream_handshake_frame( - sender: &mpsc::Sender, -) -> Result<(), String> { - // Some HTTP/2 proxies do not fully establish a bidi stream until the client - // sends its first DATA frame. `detach` is a gateway no-op and is not forwarded - // to browser terminal subscribers. - sender - .try_send(terminal_stream_noop_frame("desktop-handshake")) - .map_err(|error| format!("queue gateway terminal stream handshake failed: {error}")) -} - -pub(crate) async fn queue_terminal_stream_keepalive_frame( - sender: &mpsc::Sender, -) -> Result<(), String> { - sender - .send(terminal_stream_noop_frame("desktop-keepalive")) - .await - .map_err(|error| format!("queue gateway terminal stream keepalive failed: {error}")) -} - -pub(crate) fn terminal_stream_noop_frame(prefix: &str) -> proto::TerminalStreamFrame { - proto::TerminalStreamFrame { - kind: "detach".to_string(), - stream_id: format!("{}-{}", prefix.trim(), Uuid::new_v4()), - ..Default::default() - } -} - -pub(crate) fn format_gateway_terminal_stream_rpc_error( - phase: &str, - error: &tonic::Status, - config: &RemoteSettingsPayload, -) -> String { - let message = error.to_string(); - if !is_h2_protocol_error(&message) { - return format!("gateway terminal stream {phase} failed: {message}"); - } - - let endpoint = { - // gRPC 回退路径专用的错误提示。 - #[allow(deprecated)] - build_grpc_url(config).unwrap_or_else(|_| "invalid endpoint".to_string()) - }; - format!( - "gateway terminal stream {phase} failed: {message}. \ - The gateway terminal stream requires a gRPC endpoint that supports HTTP/2 bidi streams; \ - check Remote gRPC Endpoint / gRPC port. Current endpoint: {endpoint}" - ) -} diff --git a/crates/agent-gui/src-tauri/src/services/gateway/tests.rs b/crates/agent-gui/src-tauri/src/services/gateway/tests.rs index f41c7257e..0447b52ac 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway/tests.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway/tests.rs @@ -1,13 +1,9 @@ -// build_endpoint / build_grpc_url 属 gRPC 回退路径的既有覆盖:v1 移除时一并删除。 -#[allow(deprecated)] use super::{ - build_chat_event_envelope, build_chat_runtime_snapshot_envelope, build_endpoint, - build_gateway_runtime_status_envelope, build_grpc_url, - build_local_settings_update_event_payload, chat_event_is_terminal, - format_gateway_terminal_stream_rpc_error, gateway_connection_needs_restart, - gateway_connection_stale_after, gateway_reconnect_backoff, history_share_resolve_error_code, - is_chat_runtime_wake_request_id, merge_settings_sync_snapshot, - merge_settings_update_into_snapshot, proto, queue_terminal_stream_handshake_frame, + build_chat_event_envelope, build_chat_runtime_snapshot_envelope, + build_gateway_runtime_status_envelope, build_local_settings_update_event_payload, + chat_event_is_terminal, gateway_connection_needs_restart, gateway_connection_stale_after, + gateway_reconnect_backoff, history_share_resolve_error_code, is_chat_runtime_wake_request_id, + merge_settings_sync_snapshot, merge_settings_update_into_snapshot, proto, required_terminal_project_path_key, set_disconnected_status, GatewayChatRequestEvent, GatewayChatRuntimeSnapshot, GatewayController, GatewayStatusSnapshot, RemoteChatInboxRecord, GATEWAY_CHAT_LEASE_MS, GATEWAY_CHAT_RUNNING_LEASE_MS, GATEWAY_RECONNECT_MAX, @@ -596,80 +592,6 @@ fn gateway_reconnect_backoff_is_fast_after_a_stable_session_and_bounded_on_failu assert_eq!(stable_next, GATEWAY_RECONNECT_MIN * 2); } -#[test] -// gRPC 回退路径的既有覆盖:v1 移除时与被测函数一并删除。 -#[allow(deprecated)] -fn build_https_gateway_endpoint_initializes_tls_provider() { - build_endpoint("https://agent.cnweb.org:443", Duration::from_secs(30)) - .expect("build https gateway endpoint"); -} - -#[test] -// gRPC 回退路径的既有覆盖:v1 移除时与被测函数一并删除。 -#[allow(deprecated)] -fn build_grpc_url_prefers_explicit_endpoint() { - let config = RemoteSettingsPayload { - enabled: true, - gateway_url: "https://gateway.example.com".to_string(), - grpc_port: 50051, - grpc_endpoint: "tcp.proxy.rlwy.net:12345".to_string(), - token: "dev-token".to_string(), - agent_id: "agent".to_string(), - auto_reconnect: true, - heartbeat_interval: 30, - enable_web_terminal: false, - enable_web_ssh_terminal: false, - enable_web_git: false, - enable_web_tunnels: false, - }; - - let grpc_url = build_grpc_url(&config).expect("build explicit gRPC endpoint"); - - assert_eq!(grpc_url, "http://tcp.proxy.rlwy.net:12345"); -} - -#[test] -fn terminal_stream_handshake_frame_is_gateway_noop() { - let (sender, mut receiver) = tokio::sync::mpsc::channel::(1); - - queue_terminal_stream_handshake_frame(&sender).expect("queue terminal stream handshake"); - - let frame = receiver - .try_recv() - .expect("terminal stream handshake frame"); - assert_eq!(frame.kind, "detach"); - assert!(frame.stream_id.starts_with("desktop-handshake-")); - assert!(frame.session_id.is_empty()); -} - -#[test] -fn terminal_stream_h2_error_points_to_grpc_endpoint() { - let config = RemoteSettingsPayload { - enabled: true, - gateway_url: "https://gateway.example.com".to_string(), - grpc_port: 443, - grpc_endpoint: String::new(), - token: "dev-token".to_string(), - agent_id: "agent".to_string(), - auto_reconnect: true, - heartbeat_interval: 30, - enable_web_terminal: true, - enable_web_ssh_terminal: true, - enable_web_git: false, - enable_web_tunnels: false, - }; - - let message = format_gateway_terminal_stream_rpc_error( - "receive", - &tonic::Status::internal("h2 protocol error: http2 error"), - &config, - ); - - assert!(message.contains("receive failed")); - assert!(message.contains("HTTP/2 bidi streams")); - assert!(message.contains("https://gateway.example.com")); -} - #[test] fn build_chat_event_envelope_preserves_tool_result_arguments() { let envelope = build_chat_event_envelope( diff --git a/crates/agent-gui/src-tauri/src/services/gateway/ws_transport.rs b/crates/agent-gui/src-tauri/src/services/gateway/ws_transport.rs index 664c9428e..42836f6a3 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway/ws_transport.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway/ws_transport.rs @@ -1,5 +1,6 @@ -//! v2 线协议(WebSocket+Protobuf)客户端传输层:URL 推导、子协议建连、hello 握手、prost 帧编解码, -//! 并把握手失败分为「握手层失败(可回退 v1 gRPC)」与「鉴权拒绝(不得回退)」。刻意不依赖 tauri, +//! v2 线协议(WebSocket+Protobuf)客户端传输层:URL 推导、子协议建连、hello 握手、prost 帧编解码。 +//! 一切失败(建连、握手、鉴权被拒)以错误消息上抛,由连接层统一退避重连——v1 时代 +//! 按"可否回退 gRPC"区分错误类别的机制已随 v1 删除。刻意不依赖 tauri, //! 便于纯 tokio 测试;业务信封收发主循环由 connection.rs / terminal.rs 驱动。 use std::time::Duration; @@ -22,9 +23,9 @@ use super::gateway_proto::v2; pub(crate) const GATEWAY_WS_SUBPROTOCOL: &str = "liveagent.v2.pb"; /// v2 协议版本号(`ClientHello.protocol_version`)。 pub(crate) const GATEWAY_WS_PROTOCOL_VERSION: u32 = 2; -/// 桌面端主链路路径(替代 v1 gRPC Authenticate + AgentConnect)。 +/// 桌面端主链路路径(承接 v1 gRPC Authenticate + AgentConnect 的职能,v1 已移除)。 pub(crate) const GATEWAY_WS_AGENT_PATH: &str = "/ws/v2/agent"; -/// 终端数据面路径(替代 v1 gRPC AgentTerminalConnect)。 +/// 终端数据面路径(承接 v1 gRPC AgentTerminalConnect 的职能,v1 已移除)。 pub(crate) const GATEWAY_WS_TERMINAL_PATH: &str = "/ws/v2/terminal"; /// 鉴权失败时服务端的自定义关闭码(Go 侧 `closeCodeUnauthorized`)。 pub(crate) const GATEWAY_WS_CLOSE_CODE_UNAUTHORIZED: u16 = 4401; @@ -33,27 +34,8 @@ pub(crate) const GATEWAY_WS_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10 pub(crate) type WsStream = WebSocketStream>; -/// v2 握手阶段的错误分类:决定同一次连接尝试内是否允许回退 v1 gRPC。 -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum WsHandshakeError { - /// 握手层失败(TCP/TLS/404/非 101/子协议不符/hello 超时等):视为旧网关无 /ws/v2,可回退 gRPC。 - Handshake(String), - /// 鉴权被拒(ServerHello ok=false 或 4401 关闭码):不得回退,与 v1 Authenticate 失败同等对待。 - AuthRejected(String), -} - -impl std::fmt::Display for WsHandshakeError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - WsHandshakeError::Handshake(message) => write!(f, "{message}"), - WsHandshakeError::AuthRejected(message) => write!(f, "{message}"), - } - } -} - /// 从网关基址推导 v2 WS URL:http→ws、https→wss,保留路径前缀(反代子路径),丢弃查询串与片段。 -/// 端口取设置里的 `grpc_port`(v1 命名遗留,实义网关端口)无条件覆盖基址端口,与界面预览拼法 -/// 一致;v2 与 gRPC 回退因此指向同一端口。 +/// 端口取设置里的 `grpc_port`(v1 命名遗留,实义网关端口)无条件覆盖基址端口,与界面预览拼法一致。 pub(crate) fn build_ws_url( gateway_url: &str, gateway_port: u16, @@ -109,39 +91,35 @@ pub(crate) fn decode_ws_frame(data: &[u8]) -> Result< M::decode(data).map_err(|error| format!("decode gateway v2 frame failed: {error}")) } -/// ServerHello 校验:ok=false 一律按鉴权拒绝处理(服务端随即以 4401 关闭)。 -pub(crate) fn vet_server_hello( - hello: v2::ServerHello, -) -> Result { +/// ServerHello 校验:ok=false 即鉴权被拒(服务端随即以 4401 关闭),透传服务端消息。 +pub(crate) fn vet_server_hello(hello: v2::ServerHello) -> Result { if hello.ok { return Ok(hello); } let message = hello.message.trim(); - Err(WsHandshakeError::AuthRejected(if message.is_empty() { + Err(if message.is_empty() { "gateway authentication failed".to_string() } else { message.to_string() - })) + }) } -/// hello 应答前收到关闭帧的分类:4401 为鉴权拒绝,其余按握手层失败处理。 -pub(crate) fn classify_pre_hello_close(frame: Option<&CloseFrame>) -> WsHandshakeError { +/// hello 应答前收到关闭帧的错误消息:4401 透传鉴权拒绝原因,其余带上关闭码。 +pub(crate) fn pre_hello_close_error(frame: Option<&CloseFrame>) -> String { match frame { Some(frame) if u16::from(frame.code) == GATEWAY_WS_CLOSE_CODE_UNAUTHORIZED => { let reason = frame.reason.trim(); - WsHandshakeError::AuthRejected(if reason.is_empty() { + if reason.is_empty() { "gateway authentication failed".to_string() } else { reason.to_string() - }) + } } - Some(frame) => WsHandshakeError::Handshake(format!( + Some(frame) => format!( "gateway v2 connection closed before hello (code {})", u16::from(frame.code) - )), - None => { - WsHandshakeError::Handshake("gateway v2 connection closed before hello".to_string()) - } + ), + None => "gateway v2 connection closed before hello".to_string(), } } @@ -149,7 +127,7 @@ pub(crate) fn classify_pre_hello_close(frame: Option<&CloseFrame>) -> WsHandshak pub(crate) async fn connect_agent_ws( url: &str, hello: v2::ClientHello, -) -> Result<(WsStream, v2::ServerHello), WsHandshakeError> { +) -> Result<(WsStream, v2::ServerHello), String> { let frame = encode_ws_frame(&v2::AgentClientFrame { payload: Some(v2::agent_client_frame::Payload::Hello(hello)), }); @@ -160,7 +138,7 @@ pub(crate) async fn connect_agent_ws( pub(crate) async fn connect_terminal_ws( url: &str, hello: v2::ClientHello, -) -> Result<(WsStream, v2::ServerHello), WsHandshakeError> { +) -> Result<(WsStream, v2::ServerHello), String> { let frame = encode_ws_frame(&v2::TerminalClientFrame { payload: Some(v2::terminal_client_frame::Payload::Hello(hello)), }); @@ -188,45 +166,46 @@ async fn connect_and_hello( url: &str, hello_frame: Message, decode_hello: fn(&[u8]) -> Result, String>, -) -> Result<(WsStream, v2::ServerHello), WsHandshakeError> { +) -> Result<(WsStream, v2::ServerHello), String> { let handshake = async { let mut stream = connect_ws(url).await?; - stream.send(hello_frame).await.map_err(|error| { - WsHandshakeError::Handshake(format!("send gateway v2 hello failed: {error}")) - })?; + stream + .send(hello_frame) + .await + .map_err(|error| format!("send gateway v2 hello failed: {error}"))?; let hello = await_server_hello(&mut stream, decode_hello).await?; Ok((stream, hello)) }; tokio::time::timeout(GATEWAY_WS_HANDSHAKE_TIMEOUT, handshake) .await - .map_err(|_| WsHandshakeError::Handshake("gateway v2 handshake timed out".to_string()))? + .map_err(|_| "gateway v2 handshake timed out".to_string())? } /// 以 v2 子协议发起 WS 升级并校验服务端回显(旧网关兜底路由可能接受升级却不认识 v2 帧)。 -async fn connect_ws(url: &str) -> Result { +async fn connect_ws(url: &str) -> Result { if url.starts_with("wss://") { - // rustls 连接器复用进程级默认 crypto provider,与 tonic TLS 共用同一次 ring 安装。 + // rustls 连接器复用进程级默认 crypto provider(ensure_rustls_crypto_provider 负责唯一一次 ring 安装)。 ensure_rustls_crypto_provider(); } - let mut request = url.into_client_request().map_err(|error| { - WsHandshakeError::Handshake(format!("build gateway v2 request failed: {error}")) - })?; + let mut request = url + .into_client_request() + .map_err(|error| format!("build gateway v2 request failed: {error}"))?; request.headers_mut().insert( SEC_WEBSOCKET_PROTOCOL, HeaderValue::from_static(GATEWAY_WS_SUBPROTOCOL), ); - let (stream, response) = connect_async(request).await.map_err(|error| { - WsHandshakeError::Handshake(format!("gateway v2 connect failed: {error}")) - })?; + let (stream, response) = connect_async(request) + .await + .map_err(|error| format!("gateway v2 connect failed: {error}"))?; let echoed = response .headers() .get(SEC_WEBSOCKET_PROTOCOL) .and_then(|value| value.to_str().ok()) .unwrap_or_default(); if echoed != GATEWAY_WS_SUBPROTOCOL { - return Err(WsHandshakeError::Handshake(format!( + return Err(format!( "gateway v2 subprotocol mismatch: expected {GATEWAY_WS_SUBPROTOCOL:?}, got {echoed:?}" - ))); + )); } Ok(stream) } @@ -235,24 +214,18 @@ async fn connect_ws(url: &str) -> Result { async fn await_server_hello( stream: &mut WsStream, decode_hello: fn(&[u8]) -> Result, String>, -) -> Result { +) -> Result { loop { match stream.next().await { - None => return Err(classify_pre_hello_close(None)), - Some(Err(error)) => { - return Err(WsHandshakeError::Handshake(format!( - "gateway v2 hello receive failed: {error}" - ))) - } + None => return Err(pre_hello_close_error(None)), + Some(Err(error)) => return Err(format!("gateway v2 hello receive failed: {error}")), Some(Ok(Message::Binary(data))) => { - return match decode_hello(&data).map_err(WsHandshakeError::Handshake)? { + return match decode_hello(&data)? { Some(hello) => vet_server_hello(hello), - None => Err(WsHandshakeError::Handshake( - "gateway v2 sent a non-hello first frame".to_string(), - )), + None => Err("gateway v2 sent a non-hello first frame".to_string()), }; } - Some(Ok(Message::Close(frame))) => return Err(classify_pre_hello_close(frame.as_ref())), + Some(Ok(Message::Close(frame))) => return Err(pre_hello_close_error(frame.as_ref())), // WS 控制帧(Ping/Pong)不参与握手语义。 Some(Ok(_)) => continue, } @@ -272,8 +245,12 @@ mod tests { fn build_ws_url_maps_http_to_ws() { // 端口框(gateway_port)与界面预览一致:无条件覆盖基址端口。 assert_eq!( - build_ws_url("http://gateway.example.com:8080", 8080, GATEWAY_WS_AGENT_PATH) - .expect("build ws url"), + build_ws_url( + "http://gateway.example.com:8080", + 8080, + GATEWAY_WS_AGENT_PATH + ) + .expect("build ws url"), "ws://gateway.example.com:8080/ws/v2/agent" ); } @@ -292,8 +269,12 @@ mod tests { fn build_ws_url_overrides_explicit_base_port() { // 界面预览的语义:端口框优先于基址里写的端口。 assert_eq!( - build_ws_url("http://gateway.example.com:9999", 50052, GATEWAY_WS_AGENT_PATH) - .expect("build ws url overriding base port"), + build_ws_url( + "http://gateway.example.com:9999", + 50052, + GATEWAY_WS_AGENT_PATH + ) + .expect("build ws url overriding base port"), "ws://gateway.example.com:50052/ws/v2/agent" ); } @@ -344,39 +325,32 @@ mod tests { message: " unauthorized ".to_string(), ..Default::default() }), - Err(WsHandshakeError::AuthRejected("unauthorized".to_string())) + Err("unauthorized".to_string()) ); assert_eq!( vet_server_hello(v2::ServerHello::default()), - Err(WsHandshakeError::AuthRejected( - "gateway authentication failed".to_string() - )) + Err("gateway authentication failed".to_string()) ); } #[test] - fn classify_pre_hello_close_distinguishes_unauthorized() { + fn pre_hello_close_error_surfaces_unauthorized_reason() { + // 4401 透传服务端拒绝原因;其余关闭码/无关闭帧给出带上下文的握手失败消息。 let unauthorized = CloseFrame { code: CloseCode::from(GATEWAY_WS_CLOSE_CODE_UNAUTHORIZED), reason: "unauthorized".into(), }; - assert_eq!( - classify_pre_hello_close(Some(&unauthorized)), - WsHandshakeError::AuthRejected("unauthorized".to_string()) - ); + assert_eq!(pre_hello_close_error(Some(&unauthorized)), "unauthorized"); let normal = CloseFrame { code: CloseCode::Normal, reason: "".into(), }; - assert!(matches!( - classify_pre_hello_close(Some(&normal)), - WsHandshakeError::Handshake(_) - )); - assert!(matches!( - classify_pre_hello_close(None), - WsHandshakeError::Handshake(_) - )); + assert!(pre_hello_close_error(Some(&normal)).contains("closed before hello (code")); + assert_eq!( + pre_hello_close_error(None), + "gateway v2 connection closed before hello" + ); } fn echo_subprotocol( @@ -391,9 +365,7 @@ mod tests { Ok(response) } - async fn read_binary( - ws: &mut WebSocketStream, - ) -> Vec { + async fn read_binary(ws: &mut WebSocketStream) -> Vec { loop { match ws.next().await.expect("ws frame").expect("ws message") { Message::Binary(data) => return data.to_vec(), @@ -449,9 +421,9 @@ mod tests { proto::GatewayEnvelope { request_id: envelope.request_id, timestamp: 1, - payload: Some(proto::gateway_envelope::Payload::Ping( - proto::PingRequest { timestamp: 1 }, - )), + payload: Some(proto::gateway_envelope::Payload::Ping(proto::PingRequest { + timestamp: 1, + })), }, )), })) @@ -483,7 +455,12 @@ mod tests { .expect("send envelope"); let reply = loop { - match ws.next().await.expect("reply frame").expect("reply message") { + match ws + .next() + .await + .expect("reply frame") + .expect("reply message") + { Message::Binary(data) => { break decode_ws_frame::(&data).expect("decode reply") } @@ -499,7 +476,7 @@ mod tests { } #[tokio::test] - async fn agent_handshake_rejection_is_not_a_handshake_failure() { + async fn agent_handshake_rejection_surfaces_server_message() { let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind local listener"); @@ -527,10 +504,7 @@ mod tests { build_client_hello("bad-token", "agent-1".to_string(), "0.0.0".to_string()), ) .await; - assert_eq!( - result.err(), - Some(WsHandshakeError::AuthRejected("unauthorized".to_string())) - ); + assert_eq!(result.err(), Some("unauthorized".to_string())); server.await.expect("server task"); } @@ -545,7 +519,9 @@ mod tests { let server = tokio::spawn(async move { let (stream, _) = listener.accept().await.expect("accept tcp"); // 不回显子协议:模拟旧网关上恰好接受任意升级的兜底路由。 - let _ws = tokio_tungstenite::accept_async(stream).await.expect("accept ws"); + let _ws = tokio_tungstenite::accept_async(stream) + .await + .expect("accept ws"); }); let result = connect_agent_ws( @@ -554,10 +530,10 @@ mod tests { ) .await; match result.err() { - Some(WsHandshakeError::Handshake(message)) => { + Some(message) => { assert!(message.contains("subprotocol"), "unexpected: {message}"); } - other => panic!("expected handshake failure, got {other:?}"), + None => panic!("expected handshake failure"), } server.await.expect("server task"); diff --git a/crates/agent-gui/src-tauri/src/services/gateway_bridge.rs b/crates/agent-gui/src-tauri/src/services/gateway_bridge.rs index 36b6b9690..8b2414df3 100644 --- a/crates/agent-gui/src-tauri/src/services/gateway_bridge.rs +++ b/crates/agent-gui/src-tauri/src/services/gateway_bridge.rs @@ -15,9 +15,8 @@ use crate::commands::{ settings::{load_providers, open_db}, system::{ system_create_project_folder_sync, system_import_uploaded_readable_files_sync, - system_list_skill_files_sync, system_read_skill_metadata_sync, - system_read_skill_text_sync, system_read_uploaded_image_preview_sync, - SystemReadableFileUploadInput, + system_list_skill_files_sync, system_read_skill_metadata_sync, system_read_skill_text_sync, + system_read_uploaded_image_preview_sync, SystemReadableFileUploadInput, }, }; use crate::services::automation::{ @@ -27,9 +26,8 @@ use crate::services::gateway::proto; use crate::services::memory::{ MemoryAcceptArgs, MemoryBatchArgs, MemoryDeleteArgs, MemoryDeleteProjectArgs, MemoryListArgs, MemoryOrganizeDueClaimArgs, MemoryOrganizeRunCreateArgs, MemoryOrganizeRunListArgs, - MemoryOrganizeRunReadArgs, MemoryOrganizeRunUpdateArgs, MemoryQuotaSummaryArgs, - MemoryReadArgs, MemoryRecentRejectionsArgs, MemorySearchArgs, MemoryStore, MemoryUpdateArgs, - MemoryWriteArgs, + MemoryOrganizeRunReadArgs, MemoryOrganizeRunUpdateArgs, MemoryQuotaSummaryArgs, MemoryReadArgs, + MemoryRecentRejectionsArgs, MemorySearchArgs, MemoryStore, MemoryUpdateArgs, MemoryWriteArgs, }; use crate::services::skills::system_manage_skill_sync; @@ -54,28 +52,25 @@ pub async fn handle_cron_manage( let result_json = match action.as_str() { "snapshot" => { let store = Arc::clone(&store); - let snapshot = - tauri::async_runtime::spawn_blocking(move || store.snapshot()) - .await - .map_err(|e| format!("gateway automation snapshot join failed: {e}"))??; + let snapshot = tauri::async_runtime::spawn_blocking(move || store.snapshot()) + .await + .map_err(|e| format!("gateway automation snapshot join failed: {e}"))??; serialize_cron_manage_result(&snapshot)? } "cron_apply" => { let input = parse_apply_input(&request.task_json)?; let store = Arc::clone(&store); - let response = - tauri::async_runtime::spawn_blocking(move || store.cron_apply(input)) - .await - .map_err(|e| format!("gateway cron apply join failed: {e}"))??; + let response = tauri::async_runtime::spawn_blocking(move || store.cron_apply(input)) + .await + .map_err(|e| format!("gateway cron apply join failed: {e}"))??; serialize_cron_manage_result(&response)? } "hooks_apply" => { let input = parse_apply_input(&request.task_json)?; let store = Arc::clone(&store); - let response = - tauri::async_runtime::spawn_blocking(move || store.hooks_apply(input)) - .await - .map_err(|e| format!("gateway hooks apply join failed: {e}"))??; + let response = tauri::async_runtime::spawn_blocking(move || store.hooks_apply(input)) + .await + .map_err(|e| format!("gateway hooks apply join failed: {e}"))??; serialize_cron_manage_result(&response)? } "list_runs" => { @@ -91,10 +86,9 @@ pub async fn handle_cron_manage( "clear_runs" => { let task_id = parse_required_cron_task_id(&request, "clear_runs")?; let store = Arc::clone(&store); - let cleared = - tauri::async_runtime::spawn_blocking(move || store.clear_runs(&task_id)) - .await - .map_err(|e| format!("gateway clear_runs join failed: {e}"))??; + let cleared = tauri::async_runtime::spawn_blocking(move || store.clear_runs(&task_id)) + .await + .map_err(|e| format!("gateway clear_runs join failed: {e}"))??; serialize_cron_manage_result(&json!({ "clearedCount": cleared }))? } "run_now" => { @@ -102,8 +96,8 @@ pub async fn handle_cron_manage( let store = Arc::clone(&store); let response = tauri::async_runtime::spawn_blocking(move || store.run_cron_task_now(&task_id)) - .await - .map_err(|e| format!("gateway run_now join failed: {e}"))??; + .await + .map_err(|e| format!("gateway run_now join failed: {e}"))??; serialize_cron_manage_result(&response)? } "validate" => { @@ -949,8 +943,8 @@ fn parse_runs_limit(raw: &str) -> Result { if trimmed.is_empty() { return Ok(100); } - let payload = serde_json::from_str::(trimmed) - .map_err(|e| format!("invalid runs query: {e}"))?; + let payload = + serde_json::from_str::(trimmed).map_err(|e| format!("invalid runs query: {e}"))?; Ok(payload .as_object() .and_then(|obj| obj.get("limit")) diff --git a/crates/agent-gui/src-tauri/src/services/skills/external_mcp.rs b/crates/agent-gui/src-tauri/src/services/skills/external_mcp.rs index 92c01c971..50b9981cc 100644 --- a/crates/agent-gui/src-tauri/src/services/skills/external_mcp.rs +++ b/crates/agent-gui/src-tauri/src/services/skills/external_mcp.rs @@ -18,11 +18,7 @@ const TRANSPORT_HTTP: &str = "http"; const TRANSPORT_SSE: &str = "sse"; pub(crate) fn scan_external_mcp_servers() -> Vec { - vec![ - scan_claude_code(), - scan_codex(), - scan_claude_desktop(), - ] + vec![scan_claude_code(), scan_codex(), scan_claude_desktop()] } fn scan_claude_code() -> SystemExternalMcpToolScan { @@ -65,7 +61,13 @@ fn scan_claude_code() -> SystemExternalMcpToolScan { } } - finish_scan("claude-code", scanned_paths, "~/.claude.json", servers, errors) + finish_scan( + "claude-code", + scanned_paths, + "~/.claude.json", + servers, + errors, + ) } fn scan_codex() -> SystemExternalMcpToolScan { @@ -82,7 +84,13 @@ fn scan_codex() -> SystemExternalMcpToolScan { } } - finish_scan("codex", scanned_paths, "~/.codex/config.toml", servers, errors) + finish_scan( + "codex", + scanned_paths, + "~/.codex/config.toml", + servers, + errors, + ) } fn scan_claude_desktop() -> SystemExternalMcpToolScan { @@ -114,7 +122,13 @@ fn scan_claude_desktop() -> SystemExternalMcpToolScan { } } - finish_scan("claude-desktop", scanned_paths, &display_path, servers, errors) + finish_scan( + "claude-desktop", + scanned_paths, + &display_path, + servers, + errors, + ) } fn claude_desktop_config_path() -> Option { @@ -419,7 +433,10 @@ url = "https://mcp.example.com" assert!(errors.is_empty(), "unexpected errors: {errors:?}"); assert_eq!(servers.len(), 2); - let commander = servers.iter().find(|s| s.id == "desktop-commander").unwrap(); + let commander = servers + .iter() + .find(|s| s.id == "desktop-commander") + .unwrap(); assert_eq!(commander.transport, "stdio"); assert_eq!(commander.command, "cmd"); assert_eq!(commander.timeout_ms, Some(20_000)); @@ -444,7 +461,13 @@ url = "https://mcp.example.com" collect_json_server_map(Some(&user), "user", &mut servers, &mut errors); collect_json_server_map(Some(&project), "E:/proj", &mut servers, &mut errors); - let scan = finish_scan("claude-code", vec!["~/.claude.json".into()], "", servers, errors); + let scan = finish_scan( + "claude-code", + vec!["~/.claude.json".into()], + "", + servers, + errors, + ); assert_eq!(scan.servers.len(), 2); let a = scan.servers.iter().find(|s| s.id == "a").unwrap(); assert_eq!(a.command, "user-a"); diff --git a/crates/agent-gui/src-tauri/src/services/skills/manager.rs b/crates/agent-gui/src-tauri/src/services/skills/manager.rs index 8b9d17e75..112dd3b8f 100644 --- a/crates/agent-gui/src-tauri/src/services/skills/manager.rs +++ b/crates/agent-gui/src-tauri/src/services/skills/manager.rs @@ -27,8 +27,7 @@ fn require_payload_string<'a>( key: &str, action: &str, ) -> Result<&'a str, String> { - object_string(payload, key) - .ok_or_else(|| format!("SkillsManager {action} requires {key}")) + object_string(payload, key).ok_or_else(|| format!("SkillsManager {action} requires {key}")) } pub fn system_manage_skill_sync(payload: Value) -> Result { diff --git a/crates/agent-gui/src-tauri/src/services/skills/metadata.rs b/crates/agent-gui/src-tauri/src/services/skills/metadata.rs index 4e5e3d81e..548c98096 100644 --- a/crates/agent-gui/src-tauri/src/services/skills/metadata.rs +++ b/crates/agent-gui/src-tauri/src/services/skills/metadata.rs @@ -226,7 +226,10 @@ pub(crate) fn first_readme_description_line(content: &str) -> Option { }) } -pub(crate) fn fallback_readme_description(readme_file: &Path, name: &str) -> Result { +pub(crate) fn fallback_readme_description( + readme_file: &Path, + name: &str, +) -> Result { let content = fs::read_to_string(readme_file) .map_err(|e| format!("Failed to read README.md fallback: {e}"))?; let description = first_readme_description_line(&content) diff --git a/crates/agent-gui/src-tauri/src/services/skills/mod.rs b/crates/agent-gui/src-tauri/src/services/skills/mod.rs index 6900e7473..bff62d5ac 100644 --- a/crates/agent-gui/src-tauri/src/services/skills/mod.rs +++ b/crates/agent-gui/src-tauri/src/services/skills/mod.rs @@ -40,10 +40,10 @@ pub(crate) use external::*; pub(crate) use external_mcp::*; pub(crate) use install::*; pub(crate) use jobs::*; +pub(crate) use library::*; pub use library::{ system_list_skill_files_sync, system_read_skill_metadata_sync, system_read_skill_text_sync, }; -pub(crate) use library::*; pub use manager::system_manage_skill_sync; pub(crate) use metadata::*; pub use paths::skills_root_dir; diff --git a/crates/agent-gui/src-tauri/src/services/workspace_watch/emit.rs b/crates/agent-gui/src-tauri/src/services/workspace_watch/emit.rs index 943dff22c..f8e3f8e8e 100644 --- a/crates/agent-gui/src-tauri/src/services/workspace_watch/emit.rs +++ b/crates/agent-gui/src-tauri/src/services/workspace_watch/emit.rs @@ -42,7 +42,10 @@ impl WorkspaceWatchService { truncated, }; - if let Err(error) = self.app_handle.emit(WORKSPACE_ACTIVITY_EVENT, payload.clone()) { + if let Err(error) = self + .app_handle + .emit(WORKSPACE_ACTIVITY_EVENT, payload.clone()) + { eprintln!("emit workspace activity failed: {error}"); } diff --git a/crates/agent-gui/src-tauri/src/services/workspace_watch/mod.rs b/crates/agent-gui/src-tauri/src/services/workspace_watch/mod.rs index a15d9e95b..e8b305953 100644 --- a/crates/agent-gui/src-tauri/src/services/workspace_watch/mod.rs +++ b/crates/agent-gui/src-tauri/src/services/workspace_watch/mod.rs @@ -82,11 +82,12 @@ impl WorkspaceWatchService { let desired: BTreeSet = inner.local.union(&inner.gateway).cloned().collect(); // Dropping a handle stops its watcher (native watcher teardown ends // the aggregator; the polling fallback observes the stop flag). - inner.watchers.retain(|workdir, _| desired.contains(workdir)); + inner + .watchers + .retain(|workdir, _| desired.contains(workdir)); for workdir in desired { if !inner.watchers.contains_key(&workdir) { - let handle = - watcher::spawn_workdir_watcher(workdir.clone(), Arc::downgrade(self)); + let handle = watcher::spawn_workdir_watcher(workdir.clone(), Arc::downgrade(self)); inner.watchers.insert(workdir, handle); } } diff --git a/crates/agent-gui/src-tauri/src/services/workspace_watch/watcher.rs b/crates/agent-gui/src-tauri/src/services/workspace_watch/watcher.rs index de9454292..0b9a4ef0f 100644 --- a/crates/agent-gui/src-tauri/src/services/workspace_watch/watcher.rs +++ b/crates/agent-gui/src-tauri/src/services/workspace_watch/watcher.rs @@ -105,7 +105,12 @@ impl ActivityBatch { self.changed.insert(rel); } - fn absorb(&mut self, workdir: &Path, canonical_workdir: Option<&Path>, event: notify::Result) { + fn absorb( + &mut self, + workdir: &Path, + canonical_workdir: Option<&Path>, + event: notify::Result, + ) { let event = match event { Ok(event) => event, Err(_) => { @@ -268,7 +273,9 @@ fn sample_workdir(workdir: &Path) -> PollSample { } fn mtime_of(path: &Path) -> Option { - std::fs::metadata(path).ok().and_then(|meta| meta.modified().ok()) + std::fs::metadata(path) + .ok() + .and_then(|meta| meta.modified().ok()) } fn run_poll_fallback(workdir: String, stop: Arc, service: Weak) { @@ -324,7 +331,10 @@ mod tests { #[test] fn classify_rel_path_routes_worktree_git_meta_and_ignored() { for rel in ["src/main.rs", "README.md", "a/b/c.txt", ".gitignore"] { - assert!(matches!(classify_rel_path(rel), PathClass::Worktree), "{rel}"); + assert!( + matches!(classify_rel_path(rel), PathClass::Worktree), + "{rel}" + ); } for rel in [ ".git/HEAD", @@ -337,7 +347,10 @@ mod tests { ".git/refs/heads/main", ".git/refs/remotes/origin/main", ] { - assert!(matches!(classify_rel_path(rel), PathClass::GitMeta), "{rel}"); + assert!( + matches!(classify_rel_path(rel), PathClass::GitMeta), + "{rel}" + ); } for rel in [ ".git", @@ -348,7 +361,10 @@ mod tests { ".git/logs/HEAD", ".git/refs-backup/x", ] { - assert!(matches!(classify_rel_path(rel), PathClass::Ignored), "{rel}"); + assert!( + matches!(classify_rel_path(rel), PathClass::Ignored), + "{rel}" + ); } } } diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index 23b527002..9199b6948 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -1524,12 +1524,8 @@ export const translations: Record> = { "settings.remoteDisable": "关闭远程访问", "settings.remoteGatewayConnection": "Gateway 连接", "settings.remoteGatewayUrl": "Gateway 地址", - "settings.remoteGatewayUrlHint": "云端 Gateway 的 HTTPS 地址,用于 WebUI 访问和 gRPC 连接", - "settings.remoteGrpcPort": "gRPC 端口", - "settings.remoteGrpcPortHint": "Gateway 上 gRPC 服务的监听端口,默认 50051", - "settings.remoteGrpcEndpoint": "gRPC Endpoint", - "settings.remoteGrpcEndpointHint": - "可选。Railway TCP Proxy 等场景可填写独立 gRPC 地址,留空则使用 Gateway 地址加 gRPC 端口。", + "settings.remoteGatewayUrlHint": + "云端 Gateway 的 HTTPS 地址,用于 WebUI 访问与桌面端 v2 WebSocket 连接", "settings.remoteAuth": "身份认证", "settings.remoteToken": "访问令牌", "settings.remoteTokenPlaceholder": "输入与 Gateway 配置一致的 Token", @@ -3481,12 +3477,7 @@ export const translations: Record> = { "settings.remoteGatewayConnection": "Gateway Connection", "settings.remoteGatewayUrl": "Gateway URL", "settings.remoteGatewayUrlHint": - "HTTPS address of the cloud Gateway for WebUI access and gRPC connection", - "settings.remoteGrpcPort": "gRPC Port", - "settings.remoteGrpcPortHint": "The gRPC service port on the Gateway, default 50051", - "settings.remoteGrpcEndpoint": "gRPC Endpoint", - "settings.remoteGrpcEndpointHint": - "Optional. Use a separate gRPC address for Railway TCP Proxy or similar hosts. Leave empty to use the Gateway URL plus gRPC port.", + "HTTPS address of the cloud Gateway for WebUI access and the desktop v2 WebSocket link", "settings.remoteAuth": "Authentication", "settings.remoteToken": "Access Token", "settings.remoteTokenPlaceholder": "Enter the token matching Gateway config", diff --git a/crates/agent-gui/src/lib/settings/index.ts b/crates/agent-gui/src/lib/settings/index.ts index a2c0a41b2..5aa7d1915 100644 --- a/crates/agent-gui/src/lib/settings/index.ts +++ b/crates/agent-gui/src/lib/settings/index.ts @@ -994,7 +994,7 @@ export function normalizeRemoteSettings(input: unknown): RemoteSettings { return { enabled: obj.enabled === true, gatewayUrl: normalizeBaseUrl(typeof obj.gatewayUrl === "string" ? obj.gatewayUrl : ""), - grpcPort: normalizeIntegerInRange(obj.grpcPort, 1, 65_535, 50051), + grpcPort: normalizeIntegerInRange(obj.grpcPort, 1, 65_535, 443), grpcEndpoint: normalizeGrpcEndpoint(obj.grpcEndpoint), token: normalizeApiKey(typeof obj.token === "string" ? obj.token : ""), agentId: normalizeOptionalText(obj.agentId), @@ -1955,7 +1955,7 @@ export function getDefaultSettings(): AppSettings { remote: { enabled: false, gatewayUrl: "", - grpcPort: 50051, + grpcPort: 443, grpcEndpoint: "", token: "", agentId: "", diff --git a/crates/agent-gui/src/pages/settings/RemoteSection.tsx b/crates/agent-gui/src/pages/settings/RemoteSection.tsx index 3597e5877..4b4029498 100644 --- a/crates/agent-gui/src/pages/settings/RemoteSection.tsx +++ b/crates/agent-gui/src/pages/settings/RemoteSection.tsx @@ -198,28 +198,20 @@ function usePositiveIntegerDraft( }; } -function buildGrpcEndpoint(settings: AppSettings["remote"]) { - const explicitEndpoint = settings.grpcEndpoint.trim(); - if (explicitEndpoint) { - if (/^https?:\/\//i.test(explicitEndpoint)) { - return explicitEndpoint.replace(/\/$/, ""); - } - return `http://${explicitEndpoint.replace(/\/$/, "")}`; - } - +function buildGatewayEndpointPreview(settings: AppSettings["remote"]) { const gatewayUrl = settings.gatewayUrl.trim(); if (!gatewayUrl) return ""; try { const url = new URL(gatewayUrl); - const port = String(settings.grpcPort || 50051); + const port = String(settings.grpcPort || 443); url.port = port; url.pathname = ""; url.search = ""; url.hash = ""; return url.toString().replace(/\/$/, ""); } catch { - return `${gatewayUrl}:${settings.grpcPort || 50051}`; + return `${gatewayUrl}:${settings.grpcPort || 443}`; } } @@ -313,7 +305,10 @@ export function RemoteSection(props: SettingsSectionProps) { }, []); const isConnected = Boolean(status.online); - const grpcEndpoint = useMemo(() => buildGrpcEndpoint(settings.remote), [settings.remote]); + const gatewayEndpointPreview = useMemo( + () => buildGatewayEndpointPreview(settings.remote), + [settings.remote], + ); const connectedProtocol = status.protocol?.trim(); const statusText = isConnected @@ -395,7 +390,7 @@ export function RemoteSection(props: SettingsSectionProps) { value={remoteGrpcPortDraft.draft} onBlur={remoteGrpcPortDraft.handleBlur} onChange={(e) => remoteGrpcPortDraft.handleChange(e.target.value)} - placeholder="50051" + placeholder="443" className="w-24 shrink-0 font-mono text-[13px]" /> @@ -404,32 +399,11 @@ export function RemoteSection(props: SettingsSectionProps) {

-
- - - updateRemoteSettings(setSettings, { - grpcEndpoint: e.target.value, - }) - } - placeholder="http://tcp.proxy.rlwy.net:12345" - className="font-mono text-[13px]" - /> -

- {t("settings.remoteGrpcEndpointHint")} -

-
- - {grpcEndpoint ? ( + {gatewayEndpointPreview ? (
- {grpcEndpoint} - + {gatewayEndpointPreview} +
) : null} diff --git a/crates/agent-gui/test/settings/normalization.test.mjs b/crates/agent-gui/test/settings/normalization.test.mjs index 4f7650e03..1040fff2b 100644 --- a/crates/agent-gui/test/settings/normalization.test.mjs +++ b/crates/agent-gui/test/settings/normalization.test.mjs @@ -2041,7 +2041,7 @@ test("mcp and remote settings normalize transport, selection, ports, and tokens" }); assert.equal(remote.gatewayUrl, "http://127.0.0.1:8787"); - assert.equal(remote.grpcPort, 50051); + assert.equal(remote.grpcPort, 443); assert.equal(remote.grpcEndpoint, "tcp.proxy.rlwy.net:12345"); assert.equal(remote.token, "secret"); assert.equal(remote.autoReconnect, false); diff --git a/docs/README.md b/docs/README.md index 4ccbe0e97..4e2a2fb30 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,7 +12,7 @@ LiveAgent 是一个以桌面端为本地执行核心的 Agent 应用:GUI 负 |---|---|---| | [architecture/overview.md](architecture/overview.md) | 系统总览、进程边界、数据流、持久化地图 | 新接手项目者 | | [architecture/gui.md](architecture/gui.md) | 桌面 GUI、Tauri commands/services/runtime、设置与本地执行 | 前端与桌面端开发 | -| [architecture/gateway.md](architecture/gateway.md) | Go Gateway 的 HTTP/gRPC/WebSocket、Session Manager、缓冲与认证 | Gateway 开发与排障 | +| [architecture/gateway.md](architecture/gateway.md) | Go Gateway 的 HTTP/WebSocket(v2)、Session Manager、缓冲与认证 | Gateway 开发与排障 | | [architecture/webui.md](architecture/webui.md) | 浏览器 WebUI、socket 客户端、会话流订阅、状态与安全边界 | WebUI 开发 | | [architecture/protocols.md](architecture/protocols.md) | GUI 与 Gateway、WebUI 与 Gateway 的协议合同 | 联调与协议改造 | | [features/chat-runtime.md](features/chat-runtime.md) | 对话运行时、模型层、流式、压缩、hooks、上传与重发 | Chat 功能开发 | diff --git a/docs/architecture/gateway.md b/docs/architecture/gateway.md index 5342bce38..cc366e7f4 100644 --- a/docs/architecture/gateway.md +++ b/docs/architecture/gateway.md @@ -3,7 +3,7 @@ ## 职责边界 Gateway 是远程访问中继,不是 Agent 执行环境。它同时面对桌面 Agent 和浏览器 WebUI, -v2 起两端统一走 WebSocket+Protobuf(v1 gRPC 与 JSON WebSocket 已弃用、仅为旧客户端保留): +两端统一走 WebSocket+Protobuf(v2 协议;v1 gRPC 与 JSON WebSocket 已移除): | 方向 | 协议 | 作用 | |---|---|---| @@ -11,26 +11,21 @@ v2 起两端统一走 WebSocket+Protobuf(v1 gRPC 与 JSON WebSocket 已弃用 | Desktop/Browser -> Gateway | WebSocket `/ws/v2/terminal`(Protobuf 帧) | 专用终端字节流(角色由 hello 区分),承载 attach snapshot、input、resize、output,不与普通控制面共享队列。 | | WebUI -> Gateway | WebSocket `/ws/v2`(Protobuf 帧) | 浏览器端发起 chat(command/subscribe)、直通 history/settings/skills/memory/cron 等请求,并订阅 `chat_event` 与同步广播。 | | WebUI -> Gateway | HTTP `/api/*` | 状态检查、文件上传、公网分享页、图片代理、静态资源。 | -| ~~Desktop -> Gateway~~ | gRPC `AgentGateway.*` | **已弃用**,服务旧版桌面端。 | -| ~~WebUI -> Gateway~~ | WebSocket `/ws`、`/ws/terminal` | **已弃用**,服务未刷新的旧标签页。 | ## 入口与服务启动 | 文件 | 作用 | |---|---| -| `cmd/gateway/main.go` | 读取 config,创建 `session.Manager`,启动 gRPC server(弃用,兼容旧桌面端)与 HTTP server,处理 shutdown。 | -| `cmd/gateway/shutdown.go` | gRPC graceful stop 超时后强制 stop。 | +| `cmd/gateway/main.go` | 读取 config,创建 `session.Manager`,启动 HTTP server,处理 shutdown。 | | `internal/config/config.go` | 地址、token、TLS、静态资源、请求大小、超时等配置。 | -| `internal/observability/` | slog 初始化与 v1/v2 协议使用计数(`/api/status` 的 `protocol_usage`)。 | -| `internal/transport/wscore/` | v1/v2 共用的 WebSocket 连接运行时:控制优先双队列写泵、拥塞掉帧、有限重试、心跳与空闲驱逐。 | +| `internal/observability/` | slog 初始化与 v2 协议使用计数(`/api/status` 的 `protocol_usage`)。 | +| `internal/transport/wscore/` | WebSocket 连接运行时:控制优先双队列写泵、拥塞掉帧、有限重试、心跳与空闲驱逐。 | | `internal/protocol/pbws/` | **v2 协议层**:三链路握手/编解码、直通白名单(`guard.go`)、关联 id 命名空间化、事件扇出与快照回放。 | -| `internal/protocol/shared/` | 两代协议共用的域逻辑:Origin 校验、终端权限门控与响应后处理、终端兴趣跟踪。 | -| `internal/chatcmd/` | chat 命令编排(归一化、探活、投递、启动看门狗),v1/v2 共用。 | -| `internal/auth/` | HTTP/WS token 校验;gRPC 拦截器(弃用)。 | -| `internal/server/grpc.go` | v1 `AgentGateway` gRPC 服务实现(弃用)。 | -| `internal/server/http.go` | HTTP mux、v1/v2 WebSocket 路由、API、静态 WebUI 与 public share route。 | -| `internal/server/websocket*.go` | v1 JSON 协议实现(弃用):连接生命周期、字符串路由表、约 90 个 domain handler 与 payload 塑形。 | -| `internal/session/manager.go` | `session.Manager` façade 和核心公开类型(transport 无关,两代协议共用)。 | +| `internal/protocol/shared/` | 协议无关的域逻辑:Origin 校验、终端权限门控与响应后处理、终端兴趣跟踪。 | +| `internal/chatcmd/` | chat 命令编排(归一化、探活、投递、启动看门狗)。 | +| `internal/auth/` | HTTP/WS token 校验。 | +| `internal/server/http.go` | HTTP mux、v2 WebSocket 路由、API、静态 WebUI 与 public share route(proto→JSON 塑形见 `proto_json.go`)。 | +| `internal/session/manager.go` | `session.Manager` façade 和核心公开类型(transport 无关)。 | | `internal/session/manager_state.go` | session registry、sync hub、chat run store 的内部状态定义。 | | `internal/session/manager_registry.go` | 当前 Agent session、认证快照、per-request stream 注册。 | | `internal/session/manager_*_sync.go`、`manager_terminal.go`、`conversation_stream.go`、`conversation_ingress.go` | history/settings/terminal sync、进程内 Chat 事件窗口、实时 fan-out、replay 与 command dedupe。 | @@ -40,10 +35,8 @@ v2 起两端统一走 WebSocket+Protobuf(v1 gRPC 与 JSON WebSocket 已弃用 | 路由 | 认证 | 说明 | |---|---|---| | `GET /ws/v2` | hello token | **v2** WebUI 主链路(Protobuf 帧,子协议 `liveagent.v2.pb`)。 | -| `GET /ws/v2/agent` | hello token | **v2** 桌面端信封流(取代 gRPC)。 | +| `GET /ws/v2/agent` | hello token | **v2** 桌面端信封流。 | | `GET /ws/v2/terminal` | hello token | **v2** 终端数据面(两端共用,角色在 hello)。 | -| `GET /ws` | token | v1 WebUI JSON 协议(弃用)。 | -| `GET /ws/terminal` | token | v1 终端二进制流(弃用);首帧 JSON auth,后续 `version + kind + headerLength + JSON header + bytes`。 | | `GET /api/status` | token | Agent 在线状态 + `protocol_usage` 协议使用计数。 | | `POST /api/files/import` | token | WebUI 上传可读文件,Gateway 转发给桌面端导入 workspace uploads。 | | `GET /api/public/history-shares/{token}` | public token | 公开只读历史分享数据。 | @@ -52,17 +45,9 @@ v2 起两端统一走 WebSocket+Protobuf(v1 gRPC 与 JSON WebSocket 已弃用 Chat 走 `/ws/v2` 且是严格新协议:`chat_prepare` 用关联 Ping/Pong 探测并唤醒桌面 Chat Runtime;`chat_command` 携带 proto `ChatCommandRequest`,`chat_subscribe` 按 `conversation_id` 订阅。旧 HTTP SSE 路由 `GET /api/chat/events` 已下线。 -## gRPC 服务(已弃用) +## Proto 定义与代码生成 -仅为旧版桌面端保留;新桌面端走 `/ws/v2/agent`,握手失败时自动回退到本服务。 - -| RPC | 类型 | 用途 | -|---|---|---| -| `Authenticate(AuthRequest) -> AuthResponse` | unary | 桌面端认证探活,返回 session 信息(v2 由 hello 承担)。 | -| `AgentConnect(stream AgentEnvelope) -> stream GatewayEnvelope` | bidirectional stream | 桌面端常驻连接(v2 由 `/ws/v2/agent` 承担,信封同构)。 | -| `AgentTerminalConnect(stream TerminalStreamFrame) -> stream TerminalStreamFrame` | bidirectional stream | 终端专用字节流(v2 由 `/ws/v2/terminal` 承担)。 | - -`proto/v1/gateway.proto` 是三端共享的权威业务消息定义(Go 生成于 `internal/proto/v1/*`);v2 帧壳定义于 `proto/v2/gateway_ws.proto`(Go 生成于 `internal/proto/v2/*`)。代码生成统一由 `buf` 驱动(`make proto`),CI 有生成物漂移与 breaking 检查门禁。 +`proto/v1/gateway.proto` 是三端共享的权威业务消息定义(Go 生成于 `internal/proto/v1/*`;包名沿用 v1,消息即 v2 载荷,`service AgentGateway` 已随 v1 gRPC 删除);v2 帧壳定义于 `proto/v2/gateway_ws.proto`(Go 生成于 `internal/proto/v2/*`)。代码生成统一由 `buf` 驱动(`make proto`),CI 有生成物漂移与 breaking 检查门禁。 ## Session Manager @@ -93,19 +78,19 @@ Chat 走 `/ws/v2` 且是严格新协议:`chat_prepare` 用关联 Ping/Pong 探 | broadcast | Gateway 主动推送 `status`、`history.event`、`settings.event`、`terminal`、`sftp` 等非 Chat 同步事件。 | | chat prepare | `chat.prepare` 向当前 `AgentConnect` stream 发送带 `chat-runtime-wake-` request id 的 Ping;桌面 Rust emit WebView wake 并可靠返回关联 Pong,Gateway 收到真实往返后才响应,并记录绑定当前 session epoch 的短时新鲜度。 | | chat command | 提交/编辑/取消走 WS `chat.command`;新 command 在 accepted 前必须有原生往返 probe,紧随成功 prepare 的 command 可复用同一 session 2 秒内的新鲜结果,旧客户端或过期结果仍现场探测。accepted ACK 走控制优先队列,避免被 token 数据积压阻塞。流式事件走按 conversation 持久订阅 `chat.subscribe`,经 `chat.event` 推送(订阅缓冲溢出时发 `chat.subscription_reset` 提示客户端按游标重订阅)。 | -| terminal stream | 不走普通 `/ws` request;attach/input/resize/detach 走 `/ws/terminal` 二进制 frame,页面侧 `BrowserGatewayTerminalStreamClient` 为同一 token 复用一条 terminal stream 并按 session fan-out。 | +| terminal stream | 不走主链路 request;attach/input/resize/detach 走 `/ws/v2/terminal` proto frame,页面侧 `BrowserGatewayTerminalStreamClient` 为同一 token 复用一条 terminal stream 并按 session fan-out。 | -WebSocket server 的实现分层:`internal/transport/wscore` 管写泵/背压/心跳(两代共用);v2 在 `internal/protocol/pbws`(帧编解码、直通白名单、事件扇出);v1 在 `internal/server/websocket*.go`(弃用:连接生命周期 + 字符串路由 + domain handler)。域逻辑(终端门控/响应后处理、chat 编排)在 `internal/protocol/shared` 与 `internal/chatcmd`,两代协议调用同一份实现。 +WebSocket server 的实现分层:`internal/transport/wscore` 管写泵/背压/心跳;协议层在 `internal/protocol/pbws`(帧编解码、直通白名单、事件扇出)。域逻辑(终端门控/响应后处理、chat 编排)在 `internal/protocol/shared` 与 `internal/chatcmd`。 -Terminal metadata 事件仍通过普通 `/ws` 广播,用于同步 `created`、`exit`、`closed`、`renamed`、SSH prompt 和 SSH tab 状态;terminal output 不再进入 React session state,也不再附带完整 session。输出 bytes 只通过 `/ws/terminal` 和 `AgentTerminalConnect` 推送,慢客户端只阻塞自己的 terminal stream。 +Terminal metadata 事件通过 `/ws/v2` 广播臂(`terminal_event`)同步 `created`、`exit`、`closed`、`renamed`、SSH prompt 和 SSH tab 状态;terminal output 不进入 React session state,也不附带完整 session。输出 bytes 只通过 `/ws/v2/terminal` 推送,慢客户端只阻塞自己的 terminal stream。 ## 安全模型 | 领域 | 设计 | |---|---| -| 认证 | HTTP API 与 WebSocket 通过 token;gRPC 通过 interceptor 校验 token。 | +| 认证 | HTTP API 通过 Bearer token;WebSocket 通过 hello token(辅以 Origin 校验)。 | | Chat command 防护 | Chat 命令仅经认证后的 WebSocket `chat.command` 提交(连接级 token + Origin 校验,2 MiB payload 上限);accepted 前必须完成当前原生 stream 的关联 Ping/Pong。 | -| Chat 订阅防护 | `chat.subscribe` 仅在认证后的 `/ws` 连接上可用;每个 conversation 的 replay 受 4096 条与约 8 MiB 事件窗口硬上限保护。 | +| Chat 订阅防护 | `chat.subscribe` 仅在认证后的 `/ws/v2` 连接上可用;每个 conversation 的 replay 受 4096 条与约 8 MiB 事件窗口硬上限保护。 | | Provider API key | 普通 settings sync 不应携带真实 key;WebUI 只接收 presence/redacted 字段。 | | 文件访问 | WebUI 上传只把 bytes 交给桌面端导入,Gateway 不直接落地为任意本地路径。 | | 工具执行 | Gateway 不运行 Shell、FS、MCP、Memory mutation 等高权限工具,只转发请求到桌面端。 | @@ -118,10 +103,10 @@ Terminal metadata 事件仍通过普通 `/ws` 广播,用于同步 `created`、 |---|---|---| | Desktop offline | WebUI 请求返回 agent offline 或状态 offline | `session.Manager` 检测当前 session,WebUI 展示离线/不可用状态。 | | WebSocket 断开 | WebUI 自动重连;Chat 订阅按 `after_seq` 游标恢复 | `GatewayWebSocketClient` 统一管理重连并在重连后重发 `chat.subscribe`,Gateway 从当前进程的有界事件窗口补发;窗口不足时返回 reset,由桌面历史 snapshot 重建。 | -| gRPC stream 断开 | Agent session close,pending stream 结束 | 桌面端 remote auto reconnect 可重新建立 session;重连后桌面端 republish chat run 台账(active `started` + 未确认终态控制事件),网关幂等收养。 | -| 长时间空闲后首发 | socket、gRPC stream 或 WebView runtime 可能半开/休眠 | `chat.prepare` 在默认 2 秒内完成关联原生 Ping/Pong,紧随 command 复用 session-bound 新鲜结果;没有新鲜结果时 command 自行 probe。失败立即返回而不把命令错误标记为 accepted。 | +| 桌面端信封流断开 | Agent session close,pending stream 结束 | 桌面端 remote auto reconnect 可重新建立 session;重连后桌面端 republish chat run 台账(active `started` + 未确认终态控制事件),网关幂等收养。 | +| 长时间空闲后首发 | socket、信封流或 WebView runtime 可能半开/休眠 | `chat.prepare` 在默认 2 秒内完成关联原生 Ping/Pong,紧随 command 复用 session-bound 新鲜结果;没有新鲜结果时 command 自行 probe。失败立即返回而不把命令错误标记为 accepted。 | | Chat run 终态信号丢失 | run 已在桌面端结束但网关 activity 未清除 | 桌面端 `ChatRunLedger` 先记账再发送,5s sweeper 重发未送达终态;心跳 `RuntimeStatusEvent.active_runs/finished_runs` 驱动网关对账:finished 报告按真实终态收养,active 报告逐 run 续命,缺席且无事件/续命超过 `runReportLostTimeout`(15s)判 `failed/desktop_run_lost`。 | | Chat run 卡死兜底 | 桌面端不再上报某 run | 在线走 `staleRunTimeout`(10min,逐 run 续命,单会话忙碌不屏蔽他会话);离线走 `offlineRunTimeout`(30min)判 `failed/agent_offline`。 | | Chat run 重复提交 | 同一 Gateway 进程内,同一 `client_request_id` 重复 | 24 小时进程级原子去重返回 canonical run;用于覆盖 WebSocket ACK 丢失的一次同 ID 重试。 | | Chat command 未进入运行态 | 事件流只到 accepted/delivered 后不继续 | command path 使用默认 5 秒 `LIVEAGENT_GATEWAY_CHAT_START_TIMEOUT` 加 10 秒 `LIVEAGENT_GATEWAY_CHAT_RENDER_START_TIMEOUT` watchdog 写入 `run.failed`,避免 WebUI 无限等待。 | -| 服务退出 | Ctrl+C 后 HTTP/gRPC shutdown | `cmd/gateway/main.go` 和 `shutdown.go` 控制 graceful/force stop。 | +| 服务退出 | Ctrl+C 后 HTTP graceful shutdown | `cmd/gateway/main.go` 控制退出与超时。 | diff --git a/docs/architecture/gui.md b/docs/architecture/gui.md index e61c43c41..fb88e16ee 100644 --- a/docs/architecture/gui.md +++ b/docs/architecture/gui.md @@ -79,10 +79,10 @@ | 机制 | 当前实现 | |---|---| | 稳定 WebView listener | `useGatewayBridgeListeners` 用 ref 保存 worker id 和最新回调,effect 只在组件挂载/卸载时注册或销毁;普通 React render 不再重建 listener、制造接收空窗或重复上报 `suspended`。 | -| 原生往返唤醒 | Rust 收到 `chat-runtime-wake-` 前缀的关联 Ping 后 emit `gateway:chat-runtime-wake`;所有 Pong(唤醒与心跳)经专用出站控制通道(64 深,与数据队列 merge 进同一信封流(v2 WebSocket;旧网关回退 gRPC))`try_send` 返回,token 流打满数据队列时探测仍可被应答,且绝不阻塞 inbound receive loop。 | +| 原生往返唤醒 | Rust 收到 `chat-runtime-wake-` 前缀的关联 Ping 后 emit `gateway:chat-runtime-wake`;所有 Pong(唤醒与心跳)经专用出站控制通道(64 深,与数据队列 merge 进同一信封流(v2 WebSocket))`try_send` 返回,token 流打满数据队列时探测仍可被应答,且绝不阻塞 inbound receive loop。 | | 生命周期 nudge | `online`、`focus`、`pageshow`、`visibilitychange`、WebView `resume` 与 Tauri `RunEvent::Resumed` 会唤醒 runtime;`online`/focus 类事件经 `gateway_nudge_connection` 走 offline/stale-heartbeat 健康检查后才重建连接(不强制),仅 `RunEvent::Resumed` 保留强制重连。 | -| 快速重连 | 信封流自动重连从 250ms 指数退避到 5s(每次重连先试 v2 `/ws/v2/agent`,握手失败回退 gRPC),稳定连接 30s 后重置;stale 判断使用 heartbeat interval 加 20s(最多 60s)。 | -| inbound 优先 | `AgentConnect` 建立后立即进入 inbound receive loop。Runtime status 先恢复,settings、terminal、tunnel、process 与 run ledger 延迟 200ms 后在可中止后台任务中低优先级 replay,并在批次间 yield。 | +| 快速重连 | 信封流自动重连从 250ms 指数退避到 5s(v2 `/ws/v2/agent`),稳定连接 30s 后重置;stale 判断使用 heartbeat interval 加 20s(最多 60s)。 | +| inbound 优先 | 信封流(`/ws/v2/agent`)建立后立即进入 inbound receive loop。Runtime status 先恢复,settings、terminal、tunnel、process 与 run ledger 延迟 200ms 后在可中止后台任务中低优先级 replay,并在批次间 yield。 | | 启动空窗消除 | WebView 在 Tauri listener 异步注册完成前就先 heartbeat + drain 一次;native wake、request-ready 与 Gateway online 事件都会继续触发 drain。 | ## 本地持久化模型 diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index e7b0ad9a0..d017c85d6 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -7,7 +7,7 @@ | 桌面 GUI | `crates/agent-gui/src` | React、TypeScript、Vite、Tailwind | Chat shell、Settings、Skills Hub、MCP Hub、Memory UI、历史侧边栏、上传与流式渲染。 | | 桌面后端 | `crates/agent-gui/src-tauri/src` | Tauri 2、Rust、SQLite、tokio | 系统命令、文件/Shell/进程、MCP runtime、MemoryStore、CronManager、GatewayController、代理服务。 | | Agent 运行时 | `crates/agent-gui/src/lib/chat`、`crates/agent-gui/src/pages/chat`、`crates/agent-gui/src/lib/tools` | TypeScript、`@mariozechner/pi-ai` | 构造上下文、请求模型、执行工具、压缩上下文、持久化历史、发布 Gateway 事件。 | -| Gateway | `crates/agent-gateway` | Go、net/http、WebSocket+Protobuf(v2;gRPC 已弃用) | 桌面 Agent 与浏览器 WebUI 的远程中继、认证、会话管理、恢复缓冲、静态 WebUI 和分享页。 | +| Gateway | `crates/agent-gateway` | Go、net/http、WebSocket+Protobuf(v2) | 桌面 Agent 与浏览器 WebUI 的远程中继、认证、会话管理、恢复缓冲、静态 WebUI 和分享页。 | | WebUI | `crates/agent-gateway/web` | React、TypeScript、Vite、WebSocket | 远程浏览器端 Chat/Settings/Hub 壳层,通过 Gateway 操作本地 Agent。 | | 资料与策略 | `doc/`、`docs/` | Markdown、SQL | 历史设计、专项计划、当前架构索引。 | @@ -15,9 +15,9 @@ | 进程/运行环境 | 入口 | 和谁通信 | 权限边界 | |---|---|---|---| -| Tauri WebView | `crates/agent-gui/src/main.tsx`、`src/App.tsx` | Tauri invoke、Gateway gRPC bridge、模型 API | 用户可见桌面界面,触发本地能力但不直接访问 Rust 内部状态。 | -| Tauri Rust 进程 | `src-tauri/src/main.rs`、`src-tauri/src/lib.rs` | 前端 invoke、SQLite、OS、Gateway WebSocket v2(gRPC 回退)、MCP server | 本地高权限真相源,负责系统能力、持久化与远程桥接。 | -| Gateway Go 进程 | `crates/agent-gateway/cmd/gateway/main.go` | Desktop/Browser WebSocket v2(Protobuf 帧)、HTTP;v1 gRPC 与 JSON WS 弃用兼容 | 网络中继层,不直接执行本地工具。 | +| Tauri WebView | `crates/agent-gui/src/main.tsx`、`src/App.tsx` | Tauri invoke、Gateway bridge、模型 API | 用户可见桌面界面,触发本地能力但不直接访问 Rust 内部状态。 | +| Tauri Rust 进程 | `src-tauri/src/main.rs`、`src-tauri/src/lib.rs` | 前端 invoke、SQLite、OS、Gateway WebSocket v2、MCP server | 本地高权限真相源,负责系统能力、持久化与远程桥接。 | +| Gateway Go 进程 | `crates/agent-gateway/cmd/gateway/main.go` | Desktop/Browser WebSocket v2(Protobuf 帧)、HTTP | 网络中继层,不直接执行本地工具。 | | Browser WebUI | `crates/agent-gateway/web/src/main.tsx`、`web/src/App.tsx` | Gateway `/ws/v2`、`/api/*` | 远程 UI,仅持有 token、脱敏设置和本地浏览器缓存。 | ## 核心数据流 @@ -65,7 +65,6 @@ Browser WebUI ▼ Go Gateway ├─ HTTP/WS: /ws/v2, /ws/v2/agent, /ws/v2/terminal, /api/status, /api/files/import, /api/public/history-shares/{token} - ├─ 弃用兼容: /ws, /ws/terminal, gRPC AgentGateway.* └─ session.Manager: agent session, streams, settings/history subscribers, bounded chat relay window │ ▼ diff --git a/docs/architecture/protocol-v2-migration.md b/docs/architecture/protocol-v2-migration.md index 9663ffecb..ee1918df5 100644 --- a/docs/architecture/protocol-v2-migration.md +++ b/docs/architecture/protocol-v2-migration.md @@ -1,69 +1,77 @@ -# v2 协议迁移与 v1 移除计划 +# v2 协议迁移与 v1 移除记录(已完成) -本文档记录 v1 → v2(WebSocket+Protobuf 统一线协议)迁移的现状、观察指标 -与最终删除 v1 的操作清单。协议合同见 [protocols.md](./protocols.md)。 +本文档归档 v1 → v2(WebSocket+Protobuf 统一线协议)迁移的删除记录。 +**v1 已于 2026-07 整体移除**,网关、桌面端与 WebUI 只保留 v2;协议合同见 +[protocols.md](./protocols.md)。 -## 迁移现状 +## 移除内容(2026-07 执行) -| 组件 | 状态 | -|---|---| -| Gateway v2 服务端(`internal/protocol/pbws`,三链路) | ✅ 已上线,与 v1 并行服务 | -| 内嵌 WebUI | ✅ 已切换 v2(与网关同版本发布,lockstep) | -| 桌面端 Rust 客户端 | ✅ 新版走 v2,握手层失败自动回退 gRPC(兼容旧网关) | -| v1 弃用标记 | ✅ Go `// Deprecated:` / Rust `#[deprecated]` / TS `@deprecated` / proto `option deprecated` | -| v1 运行时打点 | ✅ `/api/status` → `protocol_usage`,v1 连接建立打 WARN 日志 | - -## 版本偏斜矩阵 - -| 客户端 | 网关 | 行为 | -|---|---|---| -| 旧浏览器标签页(v1 bundle) | 新网关 | 继续走 `/ws` v1,正常工作 | -| 新内嵌 WebUI | 新网关 | 走 `/ws/v2`(构建即 lockstep,不存在新 UI 配旧网关) | -| 新桌面端 | 旧网关(无 `/ws/v2/agent`) | 握手 404 → 同一次连接尝试内回退 gRPC;每次重连先试 v2 | -| 新桌面端 | 新网关 | 走 `/ws/v2/agent` | -| 旧桌面端 | 新网关 | 继续走 gRPC v1,正常工作 | +Go 网关: -鉴权被拒(`ServerHello{ok:false}`)不触发回退——那是配置错误,不是版本偏斜。 +- [x] 删除 `internal/server/websocket*.go` 全部 v1 文件(envelope/路由表/ + 16 个 handler/payloads/terminal_stream/roundtrip)及其测试; + `publicHistoryShare` 依赖的 proto→JSON 塑形迁至 `internal/server/proto_json.go` +- [x] 删除 `internal/server/grpc.go` 与 `cmd/gateway` 中 gRPC 监听、TLS、 + keepalive、拦截器(`internal/auth/grpc_interceptor.go`)与 `shutdown.go` +- [x] `internal/chatwire` 甄别结果:全部为 v2/session 入口塑形复用,原样保留 +- [x] `http.go` 移除 `/ws`、`/ws/terminal` 路由与 `?terminal=1` 分支; + `http_origin.go` 包装层删除(v2 直接调 `shared.OriginAllowed`) +- [x] `proto/v1/gateway.proto` 删除 `service AgentGateway`(消息全部保留, + 它们是 v2 的载荷);`buf.gen.yaml` 移除 `protoc-gen-go-grpc` 插件并 + 重新生成(`gateway_grpc.pb.go` 删除、TS 侧 service 导出消失) +- [x] `go.mod` 移除 `google.golang.org/grpc`(`go mod tidy` 后零残留) +- [x] `-grpc-addr` 转弃用 no-op(保护既有启动脚本),下个版本删除; + `GRPCMaxMessageBytes`/`-heartbeat-period` 为 v2 沿用配置,保留 +- [x] 移除 `.golangci.yml` 中 v1 路径的 SA1019 豁免 +- [x] 移除 `observability/protousage.go` 中全部 v1 计数器(`protocol_usage` + 只余 v2 键) +- [x] Makefile/CI/Dockerfile/mise 移除 gRPC 端口、`protoc-gen-go-grpc` 钉栓 -## 删除 v1 的前置条件 +桌面端 Rust: -1. `/api/status` 的 `protocol_usage` 在一个完整观察窗(建议 ≥ 2 个桌面端 - 发版周期)内满足: - - `v1_ws_connections_total` / `v1_ws_requests_total` 不再增长; - - `v1_grpc_agent_connects_total` / `v1_grpc_terminal_connects_total` 不再增长; - - `v1_ws_connections_active` 与 `v1_grpc_agent_active` 持续为 0。 -2. 桌面端自动更新覆盖率达标(旧版桌面端不再活跃)。 -3. 网关日志中 `deprecated v1 ... established` WARN 不再出现。 +- [x] 删除 `connect_and_serve_grpc`、`build_grpc_url`、`build_endpoint`、 + `insert_bearer_metadata`、gRPC 终端流与回退调度分支(v2 握手失败不再 + 回退,按普通连接错误退避重连) +- [x] `Cargo.toml` 移除 `tonic`/`tonic-prost` 运行时依赖;`build.rs` 改 + `build_client(false)` 纯消息生成,`include_proto!` 改为直接 `include!` + (`tonic-prost-build` 仍作 build 依赖驱动 prost 生成) +- [x] 设置界面移除失效的 "gRPC Endpoint" 覆盖项(两端 RemoteSection 同步); + `grpc_port`/`grpcEndpoint` 存量字段保留(前者即 v2 网关端口,命名遗留) -## 删除清单(未来版本执行) +WebUI: -Go 网关: +- [x] 甄别结果:`web/src` 无手写 v1 线格式残留;生成物中的 `AgentGateway` + service 导出随 proto 重新生成消失 -- [ ] 删除 `internal/server/websocket*.go` 全部 v1 文件(envelope/路由表/ - 16 个 handler/payloads/terminal_stream/roundtrip 残留)及其测试 -- [ ] 删除 `internal/server/grpc.go` 与 `cmd/gateway` 中 gRPC 监听、TLS、 - keepalive、拦截器(`internal/auth` 的 gRPC 部分)装配 -- [ ] 删除 `internal/chatwire` 中仅 v1 需要的 JSON 塑形(注意:入口塑形被 - v2 `payload_json` 复用,需先甄别) -- [ ] `http.go` 移除 `/ws`、`/ws/terminal` 路由与 `?terminal=1` 分支 -- [ ] `proto/v1/gateway.proto` 删除 `service AgentGateway`(**消息全部保留**, - 它们是 v2 的载荷);`buf generate` -- [ ] `go.mod` 移除 `google.golang.org/grpc`(确认无其他引用) -- [ ] `--grpc-addr` 等 gRPC 配置项先转弃用 no-op 一个版本,再删除 -- [ ] 移除 `.golangci.yml` 中 v1 路径的 SA1019 豁免 -- [ ] 移除 `observability/protousage.go` 中 v1 计数器 +文档: -桌面端 Rust: +- [x] `protocols.md` 删除 v1 附录与弃用行;`gateway.md`/`overview.md`/ + `gui.md`/`development.md`/`deployment.md`/`source-map.md` 同步;本文件归档 -- [ ] 删除 `connect_and_serve_grpc`、`build_grpc_url`、`build_endpoint`、 - `insert_bearer_metadata` 与 gRPC 终端流及回退调度分支 -- [ ] `Cargo.toml` 移除 `tonic`/`tonic-prost` 的 transport 依赖 - (prost 生成仍需要 `tonic-prost-build` 或改为纯 `prost-build`) +## 删除复审补充(同批修正) -WebUI: +- v2 直通 `GitRequest` 补上 `enable_web_git` 写操作门控(v1 handler 删除后 + 该设置一度失去唯一执行点;`pbws/guard.go` + v2 测试恢复同款语义) +- `/ws`、`/ws/terminal` 显式回 410 Gone(避免旧客户端落进 SPA fallback + 拿到 index.html) +- 桌面端默认网关端口 50051 → 443(gRPC 监听已删,50051 上无任何服务; + 两端 settings 默认值/占位符/预览同步) +- 网关启动时对非空 `-grpc-addr` 打印弃用警告 +- `proto_json.go` 数字矫正链恢复单测(公开分享页 JSON 合同) +- `protocol_usage` 的 7 个 `v1_*` 键保留一个版本、恒为 0(给外部监控/升级 + 门禁过渡窗口,语义真实——v1 流量确为零),下个版本随 `-grpc-addr` 一并删除 +- 桌面端 `WsServeError`/`WsHandshakeError` 分类层塌缩为 `Result<_, String>` + (分类只为 v1 回退决策服务,回退已删即为死抽象;错误消息原样保留) +- 两端 `buildGrpcEndpoint` 更名 `buildGatewayEndpointPreview`(预览的是 v2 + 网关连接地址,与 gRPC 无关) -- [ ] 删除标记为 `@deprecated` 的 v1 线格式残留类型/工具 +## 版本偏斜(移除后) -文档: +| 客户端 | 网关 | 行为 | +|---|---|---| +| 新桌面端 / 新 WebUI | 新网关 | v2,正常工作。 | +| 旧桌面端(仅 gRPC) | 新网关 | 无法连接,需升级桌面端。 | +| 新桌面端 | 旧网关(无 `/ws/v2/agent`) | 握手失败按普通错误退避重连(回退已删),需升级网关。 | -- [ ] `protocols.md` 删除 v1 附录;本文件归档 +移除前提在观察窗内已满足:`/api/status` `protocol_usage` 的 v1 计数停增、 +active 归零,网关日志无 `deprecated v1 ... established` WARN。 diff --git a/docs/architecture/protocols.md b/docs/architecture/protocols.md index 2eaea0860..58169bbee 100644 --- a/docs/architecture/protocols.md +++ b/docs/architecture/protocols.md @@ -3,19 +3,17 @@ ## 协议总览 自 v2 起,网关的全部实时链路统一为 **WebSocket + Protobuf**(下称 v2 协议)。 -v1 双协议(浏览器 JSON WebSocket + 桌面端 gRPC)**已弃用**,仅为旧客户端保留, -流量归零后将整体删除(见 [protocol-v2-migration.md](./protocol-v2-migration.md))。 +v1 双协议(浏览器 JSON WebSocket + 桌面端 gRPC)**已整体移除**,网关只服务 v2 +(迁移与删除记录见 [protocol-v2-migration.md](./protocol-v2-migration.md))。 | 通道 | 端点 | 方向 | 用途 | |---|---|---|---| | **v2** WebSocket | `GET /ws/v2` | WebUI <-> Gateway | 浏览器主链路:本地操作 + `GatewayEnvelope` 直通请求 + 广播事件。 | -| **v2** WebSocket | `GET /ws/v2/agent` | Desktop <-> Gateway | 桌面端常驻双向信封流(取代 gRPC `Authenticate` + `AgentConnect`)。 | +| **v2** WebSocket | `GET /ws/v2/agent` | Desktop <-> Gateway | 桌面端常驻双向信封流。 | | **v2** WebSocket | `GET /ws/v2/terminal` | 两端 <-> Gateway | 终端专用数据面(角色由 hello 区分),承载 `TerminalStreamFrame`,避免终端 IO 与 chat/settings/history 队头阻塞。 | -| HTTP API | `/api/status` | WebUI -> Gateway | Agent 在线状态 + `protocol_usage`(v1/v2 使用计数)。 | +| HTTP API | `/api/status` | WebUI -> Gateway | Agent 在线状态 + `protocol_usage`(v2 使用计数)。 | | HTTP upload | `/api/files/import` | WebUI -> Gateway -> Desktop | 上传可读文件并导入桌面 workspace。 | | Public HTTP | `/api/public/history-shares/{token}` | Browser -> Gateway | 公开只读历史分享。 | -| ~~v1 WebSocket~~ | `GET /ws`、`GET /ws/terminal` | WebUI <-> Gateway | **已弃用**:JSON 信封协议与自定义二进制终端流(见附录)。 | -| ~~v1 gRPC~~ | `AgentGateway.*` | Desktop <-> Gateway | **已弃用**:`Authenticate`/`AgentConnect`/`AgentTerminalConnect`(见附录)。 | ## v2 统一线协议 @@ -29,48 +27,46 @@ v1 双协议(浏览器 JSON WebSocket + 桌面端 gRPC)**已弃用**,仅 - 一条 WS 二进制消息 = 一条 proto 帧消息,无长度前缀;v2 路径上文本帧被忽略。 - 首帧必须为 `ClientHello{protocol_version=2, role, token, ...}`;服务端应答 `ServerHello{ok, session_id, heartbeat_period_seconds, max_message_bytes}`; - 鉴权失败以 close code 4401 关闭。agent 角色的 hello 同时完成 v1 - `Authenticate` 的会话登记职能。 -- 消息大小上限沿用 `GRPCMaxMessageBytes`(默认 64 MiB),经 `ServerHello` 通告。 + 鉴权失败以 close code 4401 关闭。agent 角色的 hello 同时完成会话登记。 +- 消息大小上限沿用 `GRPCMaxMessageBytes`(历史命名保留,默认 64 MiB),经 + `ServerHello` 通告。 ### 浏览器链路(/ws/v2) - 请求帧 `WebClientFrame{request_id, oneof payload}`;响应帧回显同一 `request_id`;广播帧 `request_id` 为空。 -- **直通请求** `agent_request`(浏览器直接构造 v1 `GatewayEnvelope` 载荷臂): - 网关按白名单与限额校验(`internal/protocol/pbws/guard.go`,承接 v1 字符串 - 路由表的安全职能)、把 `request_id` 按连接命名空间化后近乎原样转发桌面端, - 响应以原始 `AgentEnvelope`(`agent_response` 臂)回送。v1 时代约 90 个 - “JSON 解码 → 手工组 proto → 手工拆 map” 处理器由这一条路径取代。 +- **直通请求** `agent_request`(浏览器直接构造 `GatewayEnvelope` 载荷臂,消息定义 + 在 `proto/v1/gateway.proto`——proto 包名沿用 v1,消息即 v2 载荷): + 网关按白名单与限额校验(`internal/protocol/pbws/guard.go`)、把 `request_id` + 按连接命名空间化后近乎原样转发桌面端,响应以原始 `AgentEnvelope` + (`agent_response` 臂)回送。v1 时代约 90 个"JSON 解码 → 手工组 proto → + 手工拆 map"处理器由这一条路径取代。 - **本地帧**(网关状态直接应答/编排):`status_get`、`chat_prepare`、 - `chat_command`(携带 v1 `ChatCommandRequest`)、`chat_subscribe`/ + `chat_command`(携带 `ChatCommandRequest`)、`chat_subscribe`/ `chat_unsubscribe`/`chat_activities`、`workspace_subscribe`/`workspace_unsubscribe`。 - **广播臂**:`history_event`/`settings_event`/`terminal_event`/`sftp_event`/ `chat_queue_event`/`tunnel_state`/`process_state`/`workspace_activity` - 直转 session 层的 v1 seam 消息;`status`/`chat_activity`/`chat_event`/ - `chat_command_update`/`chat_subscription_reset` 为 v2 新增 proto 化载荷。 - chat 事件载荷保持动态 JSON(`payload_json` bytes),与 v1 同源同形。 -- **本地错误**:`local_error`(`ErrorResponse`),对应 v1 的 `type:"error"` 信封。 + 直转 session 层的 seam 消息;`status`/`chat_activity`/`chat_event`/ + `chat_command_update`/`chat_subscription_reset` 为 proto 化载荷。 + chat 事件载荷保持动态 JSON(`payload_json` bytes)。 +- **本地错误**:`local_error`(`ErrorResponse`)。 - 心跳与背压:服务端 WS 控制帧 ping + 应用层 `PingFrame` 双通道;空闲驱逐 `3×心跳周期+宽限`;写侧为控制优先双队列 + 可掉帧数据 + 关联响应掉帧即 - 断连(与 v1 完全同一套 `internal/transport/wscore` 运行时)。 + 断连(`internal/transport/wscore` 连接运行时)。 ### 桌面端链路(/ws/v2/agent) -hello(role=AGENT)完成鉴权与会话登记后,双向信封流语义与 v1 gRPC -`AgentConnect` 完全一致:网关下行 `GatewayEnvelope`(请求 + 周期 Ping), -桌面端上行 `AgentEnvelope`(响应/事件/Pong);心跳走独立通道不受数据 -拥塞影响;传输层保活由 WS 控制帧 ping/pong 承担(取代 h2 keepalive), -客户端以 3×心跳周期无入站为断链判据。 +hello(role=AGENT)完成鉴权与会话登记后进入双向信封流:网关下行 +`GatewayEnvelope`(请求 + 周期 Ping),桌面端上行 `AgentEnvelope` +(响应/事件/Pong);心跳走独立通道不受数据拥塞影响;传输层保活由 WS 控制帧 +ping/pong 承担,客户端以 3×心跳周期无入站为断链判据。 ### 终端链路(/ws/v2/terminal) 两端共用一条路径,hello.role 区分浏览器/桌面端;hello 之后双向承载 -`TerminalStreamFrame`(proto 直传,淘汰 v1 浏览器侧的 -`[ver][kind][len][JSON 头][bytes]` 手工帧)。浏览器侧语义不变:attach/detach -维护本连接订阅集,input/resize 需已附着,output 只投递给已附着连接; -桌面端侧对应 v1 `AgentTerminalConnect`(就绪信号由 `ServerHello` 承担, -不再发送合成 ready 帧)。 +`TerminalStreamFrame`(proto 直传)。浏览器侧语义:attach/detach 维护本连接 +订阅集,input/resize 需已附着,output 只投递给已附着连接;桌面端侧就绪信号 +由 `ServerHello` 承担。 ## Chat 协议 @@ -83,7 +79,7 @@ hello(role=AGENT)完成鉴权与会话登记后,双向信封流语义与 v | 取消 | `chat_command`,`type=chat.cancel` | `ChatCommandRequest{type=chat.cancel}` | Gateway 置 `cancelling` 状态,桌面端真实终态优先,超时由 watchdog 兜底 `run_finished(cancelled)`。 | | 完成 | 无 | 无 | `ChatEvent.type=DONE` 映射为 `run.completed` 终态。 | -桌面端仍通过 `ChatEvent` 表达 `TOKEN`、`THINKING`、`TOOL_CALL`、`TOOL_RESULT`、`DONE`、`ERROR`、`TOOL_STATUS`、`HOSTED_SEARCH` 等低层事件。Gateway 对外统一附加同 conversation 内单调递增的 `seq`,并把控制事件规范化为 `run.accepted`、`user.message.appended`、`conversation.rebased`、`projection.updated`、`run.completed`、`run.failed`、`run.cancelled` 等 WebUI 事件。命令编排逻辑(去重、探活、接受回执、启动看门狗)收敛于 `internal/chatcmd`,v1/v2 共用。 +桌面端仍通过 `ChatEvent` 表达 `TOKEN`、`THINKING`、`TOOL_CALL`、`TOOL_RESULT`、`DONE`、`ERROR`、`TOOL_STATUS`、`HOSTED_SEARCH` 等低层事件。Gateway 对外统一附加同 conversation 内单调递增的 `seq`,并把控制事件规范化为 `run.accepted`、`user.message.appended`、`conversation.rebased`、`projection.updated`、`run.completed`、`run.failed`、`run.cancelled` 等 WebUI 事件。命令编排逻辑(去重、探活、接受回执、启动看门狗)收敛于 `internal/chatcmd`。 WebUI 对 command ACK 使用 4 秒上限。连接中断或 ACK 丢失时仅重试一次,并复用完全相同的 payload 与 `client_request_id`;Gateway 在同一进程内原子返回 canonical run,因此不会重复 seed 或 dispatch。成功 prepare 的探测新鲜度绑定 Agent session epoch 并保留 2 秒,紧随 command 可直接复用,避免正常路径重复原生 RTT;`chat_accepted` 与 `chat_prepare` 响应走 WebSocket 控制优先队列,避免被 token 数据帧队头阻塞。 @@ -196,20 +192,3 @@ Git 面板与文件树不再轮询:桌面端 `workspace_watch` 服务(notify | 新增 history 字段 | Rust summary model、proto `ConversationSummary`、GUI/WebUI sidebar render 都要同步。 | | 新增 chat event | Desktop event publisher、proto enum、Gateway 事件规范化与 `chat_event` payload、WebUI event reducer/transcript 都要同步。 | | 涉及 secret | 默认不进普通 sync,必须设计单向或显式更新通道。 | - -## 附录:v1 协议(已弃用) - -以下合同仅为旧客户端保留(未刷新的浏览器标签页、旧版桌面应用),网关侧 -实现全部带 `Deprecated` 标记并计数(`/api/status` 的 `protocol_usage`); -删除计划见 [protocol-v2-migration.md](./protocol-v2-migration.md)。 - -- **浏览器 JSON WebSocket**(`GET /ws`):文本帧 JSON 信封 - `{id, type, payload, error}`;首帧 `type:"auth"`;约 95 个 `type` 字符串 - 路由(`internal/server/websocket_routes.go`);响应 `type:"response"`、 - 错误 `type:"error"`、广播 `.event`、心跳 `ping`/`pong`。 -- **浏览器终端流**(`GET /ws/terminal`,或 `/ws?terminal=1`):首帧 JSON - auth,之后 `version(1)+kind(1)+headerLength(2,BE)+JSON header+bytes` - 自定义二进制帧。 -- **桌面端 gRPC**(`AgentGateway` 服务):`Authenticate` unary + - `AgentConnect`/`AgentTerminalConnect` 双向流;业务信封与 v2 完全同构 - (同一份 proto 消息)。 diff --git a/docs/operations/deployment.md b/docs/operations/deployment.md index 73008c3c0..306ff48da 100644 --- a/docs/operations/deployment.md +++ b/docs/operations/deployment.md @@ -24,9 +24,9 @@ | 变量 | 必填 | 说明 | |---|---|---| -| `LIVEAGENT_GATEWAY_TOKEN` | 是 | WebUI、HTTP API、桌面 gRPC 的共享访问 token。 | -| `PORT` | Railway 自动提供 | HTTP/WebUI 监听端口,未提供时 Dockerfile 默认 `8080`。 | -| `LIVEAGENT_GATEWAY_GRPC_ADDR` | 否 | gRPC 监听地址,默认 `:50051`。 | +| `LIVEAGENT_GATEWAY_TOKEN` | 是 | WebUI、HTTP API、桌面端 v2 WebSocket 的共享访问 token。 | +| `PORT` | Railway 自动提供 | HTTP/WebUI/桌面端 WebSocket 监听端口,未提供时 Dockerfile 默认 `8080`。 | +| `LIVEAGENT_GATEWAY_GRPC_ADDR` | 否 | **已弃用 no-op**:v1 gRPC 监听已移除,设置后启动时打印警告;保留仅为兼容旧启动脚本。 | | `LIVEAGENT_GATEWAY_CHAT_PREPARE_TIMEOUT` | 否 | `chat.prepare` 与 command accepted 前关联原生 Ping/Pong 的最大等待时间,默认 `2s`。 | | `LIVEAGENT_GATEWAY_CHAT_DELIVERY_TIMEOUT` | 否 | accepted 后把 `ChatCommandRequest` 投递到当前桌面 Agent stream 的最大等待时间,默认 `5s`。 | | `LIVEAGENT_GATEWAY_CHAT_START_TIMEOUT` | 否 | Chat command 进入桌面运行态的第一段 watchdog,默认 `5s`。 | @@ -50,24 +50,21 @@ Railway 自部署路径: 2. 选择 `Stack-Cairn/LiveAgent` 或用户自己的 fork。 3. 分支选择包含根目录 `Dockerfile` 和 `railway.json` 的分支。 4. 在 service variables 中设置 `LIVEAGENT_GATEWAY_TOKEN=`。 -5. 保持 `LIVEAGENT_GATEWAY_GRPC_ADDR=:50051`,或按平台 TCP Proxy 要求调整。 -6. 部署成功后生成 Public Domain,并访问 `/healthz` 验证健康检查。 +5. 部署成功后生成 Public Domain,并访问 `/healthz` 验证健康检查。 推荐生产部署模型: | 流量 | Railway 能力 | Remote 配置 | |---|---|---| -| WebUI / HTTP / WebSocket | Public Networking HTTPS 域名 | `Gateway URL=https://.up.railway.app` | -| 桌面端 gRPC | TCP Proxy | `gRPC Endpoint=http://:` | +| WebUI / HTTP / 桌面端 WebSocket(`/ws/v2*`) | Public Networking HTTPS 域名 | 桌面端设置 `Gateway URL=https://.up.railway.app`,网关端口填 `443`。 | -Gateway WebUI 和桌面 gRPC 地址分开后,Railway 的 HTTPS 域名和 TCP Proxy 地址可以独立配置。 +v2 起全部实时链路统一走同一 HTTPS 域名与端口,不再需要 TCP Proxy 或独立 gRPC 地址。 Gateway 运行时变量由用户在自己的平台配置: | 变量 | 说明 | |---|---| -| `LIVEAGENT_GATEWAY_TOKEN` | WebUI、HTTP API、桌面 gRPC 的共享访问 token。 | -| `LIVEAGENT_GATEWAY_GRPC_ADDR` | 保持 `:50051`,供 Railway TCP Proxy 转发。 | +| `LIVEAGENT_GATEWAY_TOKEN` | WebUI、HTTP API、桌面端 v2 WebSocket 的共享访问 token。 | | `LIVEAGENT_GATEWAY_CHAT_PREPARE_TIMEOUT` | 默认 `2s`;通常无需调大,超时应暴露半开连接并让客户端快速恢复。 | | `LIVEAGENT_GATEWAY_CHAT_DELIVERY_TIMEOUT` | 默认 `5s`;控制 accepted 后投递桌面 stream 的上限。 | | `LIVEAGENT_GATEWAY_CHAT_START_TIMEOUT` | 默认 `5s`;控制远程 command 启动 watchdog 的第一阶段。 | diff --git a/docs/operations/development.md b/docs/operations/development.md index 4741fd751..e6e2f784e 100644 --- a/docs/operations/development.md +++ b/docs/operations/development.md @@ -60,13 +60,12 @@ | 项 | 说明 | |---|---| -| HTTP | `internal/server/http.go` 注册 `/ws`、`/api/status`、`/api/files/import`、public share 和静态资源。 | -| gRPC | `cmd/gateway/main.go` 创建 `AgentGateway` gRPC server,并启用 token interceptor。 | +| HTTP | `internal/server/http.go` 注册 `/ws/v2*` 三链路、`/api/status`、`/api/files/import`、public share 和静态资源。 | | Proto | 改 `proto/v1|v2/*.proto` 后执行 `make proto`(buf 生成 Go+TS),生成物随源同 PR 提交;`make proto-check` 把关破坏性变更。 | -| Shutdown | `make dev-gateway` 应支持 Ctrl+C 后 HTTP 与 gRPC 干净退出。 | +| Shutdown | `make dev-gateway` 应支持 Ctrl+C 后 HTTP 干净退出。 | | WebUI embed | Gateway build 通常依赖 `make webui` 先产出静态资源。 | | 新增桌面端能力 | v1 envelope 加臂(编号只增不改)→ `make proto` → v2 直通白名单(`internal/protocol/pbws/guard.go`)放行 → 各端生成物随源同 PR 提交;新增网关本地操作则在 v2 帧(`proto/v2/gateway_ws.proto`)加臂。 | -| v1 弃用惯例 | Go `// Deprecated: <原因;替代物;删除条件>`、Rust `#[deprecated]`、TS `@deprecated`、proto `option deprecated`;弃用代码原地保留只修 bug;删除条件看 `/api/status` 的 `protocol_usage` v1 计数归零(清单见 [protocol-v2-migration.md](../architecture/protocol-v2-migration.md))。 | +| 弃用惯例 | Go `// Deprecated: <原因;替代物;删除条件>`、Rust `#[deprecated]`、TS `@deprecated`、proto `option deprecated`;弃用代码原地保留只修 bug,删除前先经使用打点观察(v1 协议已按此流程移除,记录见 [protocol-v2-migration.md](../architecture/protocol-v2-migration.md))。 | ## Gateway 分层(新代码放哪里) @@ -78,7 +77,7 @@ | chat 命令编排 | `internal/chatcmd` | | 会话状态与关联路由(transport 无关) | `internal/session` | | 日志装置与协议使用打点 | `internal/observability` | -| v1 遗留实现(弃用,只修 bug 不加功能) | `internal/server` | +| HTTP 入口与 public share | `internal/server` | ## GUI/WebUI 双端改造检查 diff --git a/docs/reference/source-map.md b/docs/reference/source-map.md index 1e72aa57e..483d1cfa2 100644 --- a/docs/reference/source-map.md +++ b/docs/reference/source-map.md @@ -76,16 +76,13 @@ | 功能 | 路径 | |---|---| | Gateway entry | `crates/agent-gateway/cmd/gateway/main.go` | -| Shutdown helper | `crates/agent-gateway/cmd/gateway/shutdown.go` | | Config | `crates/agent-gateway/internal/config/config.go` | | v2 协议层(WebSocket+Protobuf) | `crates/agent-gateway/internal/protocol/pbws/*`(browser/agent/terminal 三链路、guard 白名单、seam 映射) | -| WS 连接运行时(v1/v2 共用) | `crates/agent-gateway/internal/transport/wscore/*` | +| WS 连接运行时 | `crates/agent-gateway/internal/transport/wscore/*` | | 协议共用域逻辑 | `crates/agent-gateway/internal/protocol/shared/*`(Origin 校验、终端门控/后处理、终端兴趣跟踪) | -| Chat 命令编排(v1/v2 共用) | `crates/agent-gateway/internal/chatcmd/chatcmd.go` | -| 可观测性 | `crates/agent-gateway/internal/observability/*`(slog 初始化、v1/v2 使用计数) | -| gRPC server(弃用) | `crates/agent-gateway/internal/server/grpc.go` | -| HTTP routes | `crates/agent-gateway/internal/server/http.go` | -| v1 WebSocket server(弃用) | `crates/agent-gateway/internal/server/websocket.go`、`websocket_routes.go`、`websocket_*_handlers.go`、`websocket_payloads.go`、`websocket_roundtrip.go`、`websocket_terminal_stream.go` | +| Chat 命令编排 | `crates/agent-gateway/internal/chatcmd/chatcmd.go` | +| 可观测性 | `crates/agent-gateway/internal/observability/*`(slog 初始化、v2 使用计数) | +| HTTP routes | `crates/agent-gateway/internal/server/http.go`(proto→JSON 塑形:`proto_json.go`) | | Session manager | `crates/agent-gateway/internal/session/manager.go`、`agent_session.go`、`manager_state.go`、`manager_registry.go`、`manager_*_sync.go`、`manager_terminal.go`、`manager_chat_runs.go` | | Auth | `crates/agent-gateway/internal/auth/*` | | Handlers | `crates/agent-gateway/internal/handler/*` | diff --git a/mise.toml b/mise.toml index a6b56bcd0..9a899b4b9 100644 --- a/mise.toml +++ b/mise.toml @@ -5,6 +5,5 @@ node = "22.19.0" "npm:pnpm" = "10.32.1" protoc = "34.0" "go:google.golang.org/protobuf/cmd/protoc-gen-go" = "1.36.11" -"go:google.golang.org/grpc/cmd/protoc-gen-go-grpc" = "1.6.2" buf = "1.71.0" golangci-lint = "2.12.2"