big refactor in progress

This commit is contained in:
George Pickett
2026-03-02 17:18:53 -08:00
parent 9c739306a0
commit e1b78696da
105 changed files with 2589 additions and 4298 deletions
+76 -189
View File
@@ -1,203 +1,90 @@
# Architecture
## High-level overview & goals
OpenClaw Studio is a gateway-first, single-user Next.js App Router UI for managing OpenClaw agents. It provides:
- A focused UI with fleet list, primary agent panel, and inspect sidebar.
- Local persistence for gateway connection + focused-view preferences via a JSON settings file.
- A same-origin WebSocket bridge (`/api/gateway/ws`) from browser to the upstream OpenClaw gateway.
- Gateway-backed edits for agent config and agent files.
## Overview
OpenClaw Studio now uses a single runtime architecture:
Primary goals:
- **Gateway-first**: agents, sessions, and config live in the gateway; Studio stores only UI settings.
- **Remote-friendly**: tailnet/remote gateways are first-class.
- **Clear boundaries**: client UI vs server routes vs external gateway/config.
- **Predictable state**: gateway is source of truth; local settings only for focused preferences + connection.
- **Maintainability**: feature-focused modules, minimal abstractions.
1. Browser -> Studio domain APIs (`/api/runtime/*`, `/api/intents/*`)
2. Browser -> Studio SSE stream (`/api/runtime/stream`)
3. Studio server -> OpenClaw Gateway (server-owned WebSocket adapter)
Non-goals:
- Multi-tenant or multi-user concurrency.
- Server-side rendering of data from external services.
The browser no longer opens a direct gateway transport and no `/api/gateway/ws` bridge exists in production runtime.
## Architectural style
**Layered + vertical slice (feature-first)** within Next.js App Router:
- UI components + feature state in `src/features`.
- Shared utilities and adapters in `src/lib`.
- Server-side route handlers under `src/app/api`.
## Core boundaries
This keeps feature cohesion high while preserving a clear client/server boundary.
### Browser boundary
- UI state and orchestration live under `src/features/agents` and `src/app/page.tsx`.
- Browser reads and writes only through Studio HTTP routes and SSE.
- Runtime events are consumed from `/api/runtime/stream` and funneled through `gatewayRuntimeEventHandler` and approval ingress workflows.
## Main modules / bounded contexts
- **Focused agent UI** (`src/features/agents`): focused agent panel, fleet sidebar, inspect panel, and local in-memory state + actions. The fleet sidebar keeps the same **New Agent** entry point, now implemented as a one-step `AgentCreateModal` flow (name + avatar + launch). Creation is create-only: `src/app/page.tsx` calls `runCreateAgentMutationLifecycle` and `createGatewayAgent`, then reloads fleet state and focuses chat for the new agent; no guided setup compilation, deferred setup persistence, or pending retry UI remains in runtime flows. Agents render a status-first summary and latest-update preview driven by gateway events. Per-agent runtime controls (`model`, `thinking`) live in the chat header (`AgentChatPanel`), active runs can be stopped from the chat composer via `chat.abort`, and pending exec approvals render in-chat action cards (`Allow once`, `Always allow`, `Deny`) while fleet rows show `Needs approval`. Settings sidebar actions remain focused on rename, display toggles, permission updates (`updateAgentPermissionsViaStudio`), new session, cron list/run/delete/create, and delete (`AgentSettingsPanel`). The Skills section in `AgentSettingsPanel` is split into `Access` (per-agent allowlist mode + toggles) and `Library` (gateway-wide setup actions in modal flow). Cron creation continues to use a guided modal scoped to the selected settings agent. Gateway event classification (`presence`/`heartbeat` summary refresh and `chat`/`agent` runtime streams) is centralized in bridge helpers (`src/features/agents/state/runtimeEventBridge.ts`), while runtime flow decisions are emitted from pure policy helpers (`src/features/agents/state/runtimeEventPolicy.ts`) and executed by `src/features/agents/state/gatewayRuntimeEventHandler.ts`; both are consumed from one gateway subscription path in `src/app/page.tsx`, where exec approval events are handled in parallel. Higher-level orchestration is factored into operations under `src/features/agents/operations/` (fleet hydration snapshots in `agentFleetHydration.ts`, pure fleet hydration derivation in `agentFleetHydrationDerivation.ts`, chat send in `chatSendOperation.ts`, cron create in `cronCreateOperation.ts`, mutation lifecycle policy (create/rename/delete) in `mutationLifecycleWorkflow.ts`, latest-update policy in `latestUpdateWorkflow.ts`, fleet summary/reconcile policy in `fleetLifecycleWorkflow.ts`, reconcile operation adapter in `agentReconcileOperation.ts`, history request/disposition policy in `historyLifecycleWorkflow.ts`, history sync operation adapter in `historySyncOperation.ts`, approval lifecycle policy in `src/features/agents/approvals/execApprovalLifecycleWorkflow.ts`, manual exec approval resolve operation in `src/features/agents/approvals/execApprovalResolveOperation.ts`, and execution primitives in `useConfigMutationQueue.ts` and `useGatewayRestartBlock.ts`). Rename/delete post-run UI side effects are emitted as typed mutation commands from `mutationLifecycleWorkflow.ts` and executed in `src/app/page.tsx`. Session setting mutations (model/thinking) are centralized in `src/features/agents/state/sessionSettingsMutations.ts` so optimistic state updates and sync/error behavior stay aligned. Transcript ownership is split intentionally: optimistic send appends local user transcript entries while canonical timestamps and final ordering come from `chat.history` sync via the history workflow boundary (`historyLifecycleWorkflow.ts`) and operation adapter (`historySyncOperation.ts`); replayed terminal chat events and late deltas from recently closed runs are ignored in `gatewayRuntimeEventHandler`, which requests recovery history only through the `requestHistoryRefresh` boundary command. Studio fetches a capped amount of chat history by default (currently 200 messages) and exposes a “Load more” affordance when the transcript may be truncated. Disconnected startup now uses a status-first `GatewayConnectScreen` with a local command copy affordance and a collapsible remote form.
- **Studio settings** (`src/lib/studio`, `src/app/api/studio`): local settings store for gateway URL/token and focused preferences (`src/lib/studio/settings.ts`, `src/lib/studio/settings-store.ts`, `src/app/api/studio/route.ts`). `src/lib/studio/coordinator.ts` now owns both the `/api/studio` transport helpers and shared client-side load/patch scheduling for gateway and focused settings. The `/api/studio` envelope also carries `domainApiModeEnabled`, and the UI treats that server-reported flag as the authoritative domain-mode source.
- **Gateway** (`src/lib/gateway`): WebSocket client for agent runtime (frames, connect, request/response). Session settings sync transport (`sessions.patch`) is centralized in `src/lib/gateway/GatewayClient.ts`. Connect failures surfaced through the Studio WS proxy are preserved as `GatewayResponseError` codes (parsed from `connect failed: <CODE> ...`) so `useGatewayConnection` can gate auto-retry via `resolveGatewayAutoRetryDelayMs`. The OpenClaw control UI client is vendored in `src/lib/gateway/openclaw/GatewayBrowserClient.ts` with a sync script at `scripts/sync-openclaw-gateway-client.ts`.
- **Studio gateway proxy server** (`server/index.js`, `server/gateway-proxy.js`, `server/studio-settings.js`): custom Next server that terminates browser WS at `/api/gateway/ws`, loads upstream gateway URL/token server-side, injects auth token when needed, and forwards frames to the upstream gateway.
- **Gateway SSH helpers** (`src/lib/ssh/gateway-host.ts`): shared SSH target resolution and JSON-over-SSH execution for server routes. `runSshJson` centralizes `ssh -o BatchMode=yes` invocation, JSON parsing, and actionable error extraction; callers with large payloads (for example base64 media reads) can opt into a higher `maxBuffer` rather than duplicating `spawnSync` calls.
- **Gateway-backed config + agent-file edits** (`src/lib/gateway/agentConfig.ts`, `src/lib/gateway/agentFiles.ts`, `src/lib/gateway/execApprovals.ts`, `src/features/agents/components/AgentInspectPanels.tsx`): agent create/rename/heartbeat/delete and per-agent overrides via `config.get` + `config.patch`, agent file read/write via `agents.files.get` and `agents.files.set`, and per-agent exec approvals via `exec.approvals.get` + `exec.approvals.set`.
- **Heartbeat helpers** (`src/lib/gateway/agentConfig.ts`): resolves per-agent heartbeat state (enabled + schedule) by combining gateway config (`config.get`) and status (`status`) for the settings panel, triggers `wake` for “run now”, and owns the heartbeat type shapes and gateway config mutation helpers.
- **Session lifecycle actions** (`src/features/agents/state/store.tsx`, `src/app/page.tsx`): per-agent “New session” calls gateway `sessions.reset` on the current session key and resets local runtime transcript state.
- **Local OpenClaw config + paths** (`src/lib/clawdbot`): state/config path resolution with `OPENCLAW_*` env overrides (`src/lib/clawdbot/paths.ts`). Gateway URL/token in Studio are sourced from studio settings.
- **Shared agent config-list helpers** (`src/lib/gateway/agentConfig.ts`): pure `agents.list` read/write/upsert helpers used by gateway config patching to keep list-shape semantics aligned.
- **Shared utilities** (`src/lib/*`): env, ids, names, avatars, message parsing/normalization (including tool-line formatting) in `src/lib/text/message-extract.ts`, cron types + selector helpers + gateway call helpers in `src/lib/cron/types.ts`, logging, filesystem helpers.
### Server-owned control plane
- Control plane runtime modules: `src/lib/controlplane/*`
- `openclaw-adapter.ts`: upstream websocket lifecycle, handshake, request allowlist, reconnect policy.
- `runtime.ts`: process-local singleton runtime, subscription fanout, gateway call boundary.
- `projection-store.ts`: SQLite projection + outbox (`runtime.db`).
- Runtime read routes:
- `/api/runtime/summary`
- `/api/runtime/fleet`
- `/api/runtime/agents/[agentId]/history`
- `/api/runtime/stream`
- `/api/runtime/config`, `/api/runtime/models`, `/api/runtime/sessions`, `/api/runtime/chat-history`, `/api/runtime/cron`, `/api/runtime/skills/status`, `/api/runtime/agent-file`, `/api/runtime/agent-state`, `/api/runtime/media`
- Intent routes:
- `/api/intents/chat-send`, `/api/intents/chat-abort`, `/api/intents/sessions-reset`
- `/api/intents/agent-create`, `/api/intents/agent-rename`, `/api/intents/agent-delete`, `/api/intents/agent-wait`
- `/api/intents/agent-permissions-update`, `/api/intents/exec-approval-resolve`, `/api/intents/exec-approvals-set`
- `/api/intents/session-settings-sync`
- `/api/intents/cron-add`, `/api/intents/cron-run`, `/api/intents/cron-remove`, `/api/intents/cron-remove-agent`, `/api/intents/cron-restore`
- `/api/intents/skills-install`, `/api/intents/skills-update`, `/api/intents/skills-remove`, `/api/intents/agent-skills-allowlist`, `/api/intents/agent-file-set`
## Directory layout (top-level)
- `src/app`: Next.js App Router pages, layouts, global styles, and API routes.
- `src/features`: feature-first UI modules (currently focused agent-management components under `features/agents`).
- `src/lib`: domain utilities, adapters, API clients, and shared logic.
- `src/components`: shared UI components (minimal use today).
- `src/styles`: shared styling assets.
- `server`: custom Node server and WS proxy for gateway bridging + access gate.
- `public`: static assets.
- `tests`, `playwright.config.ts`, `vitest.config.ts`: automated testing.
### Settings boundary
- Studio settings route: `src/app/api/studio/route.ts`
- Persisted file: `~/.openclaw/openclaw-studio/settings.json`
- Gateway token is server-custodied and redacted from API responses.
- Gateway URL/token changes trigger deterministic control-plane reconnect via `runtime.reconnectForGatewaySettingsChange()`.
## Data flow & key boundaries
### 1) Studio settings + focused preferences
- **Source of truth**: JSON settings file at `~/.openclaw/openclaw-studio/settings.json` (resolved via `resolveStateDir` in `src/lib/clawdbot/paths.ts`). Settings store the gateway URL/token plus per-gateway focused preferences.
- **Server boundary**: `src/app/api/studio/route.ts` loads/saves settings by reading and writing `openclaw-studio/settings.json` under the resolved state dir.
- **Client boundary**: `useGatewayConnection` and focused/session flows in `src/app/page.tsx` use a shared `StudioSettingsCoordinator` to load settings and coalesce debounced `/api/studio` patch writes.
## Runtime durability model
- SQLite DB path: `${resolveStateDir()}/openclaw-studio/runtime.db`
- Projection store responsibilities:
- apply domain events idempotently
- persist ordered outbox rows
- serve replay/history windows
- SSE replay behavior:
- With `Last-Event-ID`: replay forward from that id.
- Without `Last-Event-ID`: replay recent tail window from outbox head.
Flow:
1. UI loads settings from `/api/studio`.
2. Gateway URL/token seed the connection panel and auto-connect.
3. Focused filter + selected agent are loaded for the current gateway.
4. UI schedules focused and gateway patches through the coordinator; both paths converge on `/api/studio`.
## History model
- Route: `/api/runtime/agents/[agentId]/history`
- Query:
- `limit`
- `beforeOutboxId` (exclusive cursor)
- Response:
- `entries`
- `hasMore`
- `nextBeforeOutboxId`
- Client side (`useRuntimeSyncController`) ingests fetched outbox rows into the same event pipeline as live SSE and dedupes by outbox id/time key.
### 2) Agent runtime (gateway)
- **Client-side boundary**: `GatewayClient` connects to Studio-origin `/api/gateway/ws` via `resolveStudioProxyGatewayUrl()` and wraps the vendored `GatewayBrowserClient`.
- **Server-side boundary**: custom server proxy (`server/gateway-proxy.js`) is in the middle for upstream URL/token resolution and connect-frame token injection.
## UI orchestration notes
- `src/app/page.tsx` remains top-level wiring:
- settings load
- fleet bootstrap
- stream subscription
- runtime sync polling/history load-more
- mutation controller wiring
- `runtimeWriteTransport` is intent-route based for runtime mutations.
- Settings/skills/cron/personality flows use domain clients in `src/lib/controlplane/domain-runtime-client.ts`.
Flow:
1. UI loads gateway URL/token from `/api/studio` (defaulting to `NEXT_PUBLIC_GATEWAY_URL`, or `ws://localhost:18789` when that env var is unset).
2. Browser opens WS to Studio `/api/gateway/ws` (`ws://` on `http`, `wss://` on `https`).
3. Proxy loads upstream URL/token from Studio settings on the server and opens upstream WS.
4. Proxy forwards `connect` and subsequent frames; it injects auth token server-side if the connect frame has none.
5. If upstream connect fails, the proxy sends an error response for the `connect` request (with a `studio.*` error code when possible) and closes the Studio-origin WS. The browser-side gateway client converts a failed `connect` response into a WS close with code `4008` and a reason like `connect failed: <CODE> ...`; `GatewayClient.connect()` parses this into `GatewayResponseError`, and `useGatewayConnection` decides whether/when to auto-retry based on `connectErrorCode` (through `resolveGatewayAutoRetryDelayMs`).
6. UI requests `agents.list` and builds session keys via `buildAgentMainSessionKey(agentId, mainKey)`.
7. A single gateway listener in `src/app/page.tsx` classifies `presence`/`heartbeat`/`chat`/`agent` events through `classifyGatewayEventKind` in `src/features/agents/state/runtimeEventBridge.ts`, then routes runtime payloads through `src/features/agents/state/runtimeEventPolicy.ts` and executes intents via `src/features/agents/state/gatewayRuntimeEventHandler.ts`; it also independently tracks `exec.approval.requested` / `exec.approval.resolved` for in-chat approval cards.
8. Agent store updates agent output/state.
9. Pending approval queues are pruned by expiry timestamp (with a short grace window), so stale cards and stale `awaitingUserInput` badges self-clear even when no resolved event arrives.
## Removed legacy surfaces
- Browser gateway WS transport and vendored browser gateway client are removed from production runtime.
- Server gateway WS proxy bridge (`server/gateway-proxy.js`) is removed.
- Legacy `/api/gateway/*` route namespace was re-homed to `/api/runtime/*` and `/api/intents/*`.
### 2b) Control-plane domain API mode + replay/history
- **Authoritative mode source**: `src/app/page.tsx` derives `useDomainApiMode` only from `/api/studio` (`domainApiModeEnabled === true`). Client env checks are not used for runtime request routing in the main app flow.
- **Legacy WS suppression in domain mode**: `useGatewayConnection` in `src/lib/gateway/GatewayClient.ts` does not auto-open or auto-retry `/api/gateway/ws` when `domainApiModeEnabled === true`; legacy browser WS remains available for explicit legacy-mode/diagnostic use.
- **Live stream replay contract** (`/api/runtime/stream`): with `Last-Event-ID > 0`, replay starts after that id; without `Last-Event-ID`, replay starts from the recent outbox tail (`outboxHead - REPLAY_LIMIT`) to avoid stale full-history startup replays.
- **Gap-free stream bootstrap sequencing** (`/api/runtime/stream`): stream startup subscribes first, buffers startup live rows, fetches replay from the effective cursor/floor, drains buffered rows in ascending outbox id order, and emits all rows through one monotonic id guard (`entry.id > lastDeliveredId`). This prevents replay/subscribe boundary drops and replay/live overlap duplicates in reconnect and fresh-connect paths.
- **History pagination contract** (`/api/runtime/agents/[agentId]/history`): accepts `limit` and optional `beforeOutboxId` (exclusive upper bound), returns `entries` in ascending outbox order plus `hasMore` and `nextBeforeOutboxId`. Initial reads fetch the newest window; “load more” requests pass the returned cursor.
- **Domain history ingestion**: domain history responses are ingested into the same event pipelines used by live stream handling (`gatewayRuntimeEventHandler` and exec-approval ingress) with outbox-id dedupe in `useRuntimeSyncController`.
## Error semantics
- Gateway unavailable: deterministic `GATEWAY_UNAVAILABLE` shape from intent/runtime bootstrap helpers.
- Startup/read degradation: runtime read routes can return projection/probe-backed degraded responses with freshness metadata.
- Config/approvals conflict paths keep explicit conflict handling (base-hash retry where supported).
### 3) Agent create + per-agent setup
- **Agent files**: `AGENTS.md`, `SOUL.md`, `IDENTITY.md`, `USER.md`, `TOOLS.md`, `HEARTBEAT.md`, `MEMORY.md`.
- **Create + heartbeat + rename + per-agent overrides**: stored in gateway config and updated via `config.get` + `config.patch`.
- **Exec approvals policy**: managed after creation through settings/runtime approval flows.
Flow:
1. `AgentCreateModal` captures only create payload (`name`, optional `avatarSeed`) in a one-step launch flow (`src/features/agents/components/AgentCreateModal.tsx`).
2. `runCreateAgentMutationLifecycle` enqueues one create mutation, applies queue guardrails, and drives create block status (`src/features/agents/operations/mutationLifecycleWorkflow.ts`).
3. `createGatewayAgent` calls `config.get` to derive workspace, then calls `agents.create` with `{ name, workspace }` (`src/lib/gateway/agentConfig.ts`).
4. Studio reloads fleet state and focuses the created agent chat in `src/app/page.tsx`.
5. Any authority/runtime adjustments happen after creation through settings operations such as `updateAgentPermissionsViaStudio` (`src/features/agents/operations/agentPermissionsOperation.ts`).
### 4) Cron summaries + settings controls
- **Cron**: the UI calls gateway cron methods directly (`cron.list`, `cron.add`, `cron.run`, `cron.remove`) for latest-update previews and agent settings controls.
- **Create flow**: `AgentSettingsPanel` collects a `CronCreateDraft` in a modal wizard, `buildCronJobCreateInput` maps it to a gateway-safe payload (`src/lib/cron/createPayloadBuilder.ts`), and `performCronCreateFlow` executes create + scoped refresh (`src/features/agents/operations/cronCreateOperation.ts`).
### 5) Session settings synchronization
- **UI boundary**: `AgentChatPanel` emits model/thinking callbacks from the agent header; `src/app/page.tsx` delegates both through one mutation helper.
- **Mutation boundary**: `applySessionSettingMutation` in `src/features/agents/state/sessionSettingsMutations.ts` owns optimistic store updates, `sessionCreated` guard logic, sync success updates, and user-facing failure lines.
- **Transport boundary**: `syncGatewaySessionSettings` in `src/lib/gateway/GatewayClient.ts` is the only client-side builder/invoker for `sessions.patch` payloads.
## Cross-cutting concerns
- **Configuration**: environment variables are read directly from `process.env`. The browser uses `NEXT_PUBLIC_GATEWAY_URL` only as a default upstream URL when Studio settings are missing; the Studio server persists upstream URL/token in `<state dir>/openclaw-studio/settings.json` and the WS proxy loads them via `server/studio-settings.js`. State path resolution lives in `lib/clawdbot/paths.ts`, honoring `OPENCLAW_STATE_DIR`. When Studio token is missing, settings loaders can fall back to token/port from `<state dir>/openclaw.json`. Loopback-IP gateway URLs are normalized to `localhost` in Studio settings, and the WS proxy rewrites loopback upstream origins to `localhost` for control-UI secure-context compatibility. The optional Studio access gate is enabled by `STUDIO_ACCESS_TOKEN` (`server/access-gate.js`).
- **Runtime durability + startup guard**: domain-mode runtime projection/outbox persistence is stored in `${resolveStateDir()}/openclaw-studio/runtime.db` via `better-sqlite3` (`src/lib/controlplane/projection-store.ts`). Startup scripts (`verify:native-runtime:repair` for `dev` and `verify:native-runtime:check` for `start`) verify native addon compatibility before server boot.
- **Testing**: Playwright e2e runs Studio with an isolated `OPENCLAW_STATE_DIR` so the Studio WS proxy does not read real upstream gateway settings from the developer machine.
- **Logging**: API routes and the gateway client use built-in `console.*` logging.
- **Error handling**:
- API routes return JSON `{ error }` with appropriate status.
- `fetchJson` throws when `!res.ok`, surfaces errors to UI state.
- `StudioSettingsCoordinator` logs failed async persistence writes (debounced flush or queued patch failures) so settings-save errors are observable.
- Gateway connect failures with `INVALID_REQUEST: invalid config` surface a doctor hint in Studio (`npx openclaw doctor --fix` / `pnpm openclaw doctor --fix`).
- Gateway connect failures that close with `connect failed: <CODE> ...` are preserved as `GatewayResponseError` codes so auto-retry gating can be code-driven (instead of message-driven).
- Gateway browser client truncates close reasons to WebSocket protocol limits (123 UTF-8 bytes) to avoid client-side close exceptions on long error messages.
- **Filesystem helpers**: server-only filesystem operations live at the API route boundaries. These helpers are used for local settings and gateway-adjacent file operations, not for agent file edits.
- **Remote gateway tools over SSH**: some server routes execute small scripts on the gateway host (for example agent-state operations and remote media reads). Shared helpers in `src/lib/ssh/gateway-host.ts` own SSH invocation and JSON parsing so routes do not hand-roll `spawnSync` error handling.
- **Tracing**: `src/instrumentation.ts` registers `@vercel/otel` for telemetry.
- **Validation**: request payload validation in API routes and typed client/server helpers in `src/lib/*`.
## Major design decisions & trade-offs
- **Local settings file over DB**: fast, local-first persistence for gateway connection + focused preferences; trade-off is no concurrency or multi-user support.
- **Same-origin WS proxy instead of direct browser->gateway WS**: allows server-side token custody/injection and easier local/remote switching; trade-off is one extra hop and custom-server ownership.
- **Gateway-first agent records**: records map 1:1 to `agents.list` entries with main sessions; trade-off is no local-only agent concept.
- **Gateway-backed config + agent-file edits**: create/rename/heartbeat/delete and per-agent overrides via `config.patch`, agent files via `agents.files.get`/`agents.files.set`, and per-agent exec approvals via `exec.approvals.set`; trade-off is reliance on gateway availability.
- **Fleet hydration snapshot/derive split**: `hydrateAgentFleetFromGateway` loads gateway/settings snapshots (I/O) and delegates all derived decisions (seeds, exec policy resolution, summary selection) to a pure derivation helper; trade-off is one extra module and a more explicit snapshot input, but the derivation becomes independently testable.
- **Extract page-level workflows into operations**: keep `src/app/page.tsx` as wiring by moving workflow policy into operation modules (for example reconcile in `src/features/agents/operations/agentReconcileOperation.ts` and manual exec approval resolve in `src/features/agents/approvals/execApprovalResolveOperation.ts`); trade-off is more modules, but the workflows become independently unit testable without React rendering.
- **Structured connect errors + retry policy helper**: `GatewayClient.connect()` preserves gateway connect-failure codes and `useGatewayConnection` gates auto-retry via `resolveGatewayAutoRetryDelayMs`; trade-off is one more helper plus extra state (`connectErrorCode`), but the behavior is less brittle than string matching.
- **Narrow local config mutation boundary**: Studio does not write `openclaw.json` directly today; if a local-only integration is introduced, keep any local writes narrowly scoped to that integration and reuse shared list helpers instead of ad-hoc mutation paths; trade-off is less flexibility for local-only experimentation, but clearer ownership and lower drift risk.
- **Shared `agents.list` helper layer**: gateway and local config paths now consume one pure helper module for list parsing/writing/upsert behavior; trade-off is one more shared dependency, but it reduces semantic drift and duplicate bug surface.
- **Single gateway settings endpoint**: `/api/studio` is the sole Studio gateway URL/token source; trade-off is migration pressure on any older local-config-based callers, but it removes ambiguous ownership and dead paths.
- **Shared client settings coordinator module**: `src/lib/studio/coordinator.ts` now owns `/api/studio` transport plus load/schedule/flush behavior for gateway + focused state; trade-off is introducing a central client singleton, but it removes wrapper indirection and duplicate timers/fetch paths.
- **Single shared JSON-over-SSH helper**: server routes that need to run a gateway-side script over SSH should use `runSshJson` in `src/lib/ssh/gateway-host.ts` (and opt into a larger `maxBuffer` when expecting large payloads) rather than duplicating `spawnSync` + JSON parsing; trade-off is one shared dependency, but it reduces drift risk and keeps error surfacing consistent.
- **Vendored gateway client + sync script**: reduces drift from upstream OpenClaw UI; trade-off is maintaining a sync path and local copies of upstream helpers.
- **Feature-first organization**: increases cohesion in UI; trade-off is more discipline to keep shared logic in `lib`.
- **Node runtime for API routes**: required for filesystem access and tool proxying; trade-off is Node-only server runtime.
- **Event-driven summaries + on-demand history**: keeps the dashboard lightweight; trade-off is history not being available until requested.
- **Runtime policy/executor split for event handling**: one listener path in `src/app/page.tsx` classifies frames through `src/features/agents/state/runtimeEventBridge.ts`, derives side-effect-free decisions in `src/features/agents/state/runtimeEventPolicy.ts`, and executes those intents in `src/features/agents/state/gatewayRuntimeEventHandler.ts`; trade-off is additional intent-shape maintenance, but lower coupling between lifecycle policy and side effects.
- **Single gateway event intake subscription**: one `client.onEvent` path now handles both summary-refresh events (`presence`/`heartbeat`) and runtime stream events (`chat`/`agent`) using bridge classification helpers; trade-off is a larger callback surface, but fewer lifecycle and cleanup divergence points.
- **Shared session-setting mutation path**: model and thinking-level updates now pass through one UI mutation helper plus one gateway sync helper (`src/features/agents/state/sessionSettingsMutations.ts` + `src/lib/gateway/GatewayClient.ts`), reducing divergence between optimistic state and remote patch flows.
- **Server-reported domain mode authority**: app-level runtime routing now uses `/api/studio` `domainApiModeEnabled` as the only authoritative toggle; trade-off is explicit parameter threading through operations/hooks, but it removes module-level routing divergence caused by client env fallback checks.
- **Gap-free SSE startup over outbox replay**: `/api/runtime/stream` now uses early subscribe + startup buffering + a single monotonic outbox-id emission guard to preserve ordered delivery across replay and live handoff; trade-off is slightly more route complexity, but it removes boundary-loss and duplicate-delivery failure modes under reconnect/fresh-connect race conditions.
## Mermaid diagrams
### C4 Level 1 (System Context)
```mermaid
C4Context
title OpenClaw Studio - System Context
Person(user, "User", "Operates agents locally")
System(ui, "OpenClaw Studio", "Next.js App Router UI")
System(proxy, "Studio WS Proxy", "Custom server /api/gateway/ws")
System_Ext(gateway, "OpenClaw Gateway", "WebSocket runtime")
System_Ext(fs, "Local Filesystem", "settings.json and other local reads (e.g. path suggestions)")
Rel(user, ui, "Uses")
Rel(ui, proxy, "WebSocket frames")
Rel(proxy, gateway, "WebSocket frames")
Rel(ui, fs, "HTTP to API routes -> fs read/write")
```
### C4 Level 2 (Containers/Components)
```mermaid
C4Container
title OpenClaw Studio - Containers
Person(user, "User")
Container_Boundary(app, "Next.js App") {
Container(client, "Client UI", "React", "Focused agent-management UI, state, gateway client")
Container(api, "API Routes", "Next.js route handlers", "Studio settings and gateway-host state tools")
Container(proxy, "WS Proxy", "Custom Node server", "Bridges /api/gateway/ws to upstream gateway with token injection")
}
Container_Ext(gateway, "Gateway", "WebSocket", "Agent runtime")
Container_Ext(fs, "Filesystem", "Local", "settings.json and other local reads")
Rel(user, client, "Uses")
Rel(client, api, "HTTP JSON")
Rel(client, proxy, "WebSocket /api/gateway/ws")
Rel(proxy, gateway, "WebSocket")
Rel(api, fs, "Read/Write")
Rel(proxy, fs, "Read settings/token")
```
## Explicit forbidden patterns
- Do not read/write local files directly from client components.
- Do not reintroduce local projects/workspaces as a source of truth for agent records.
- Do not write agent rename/heartbeat/override data directly to `openclaw.json`; use gateway `config.patch`.
- Do not read/write agent files on the local filesystem; use the gateway tools proxy.
- Do not add parallel gateway settings endpoints; `/api/studio` is the only supported Studio gateway URL/token path.
- Do not branch main app runtime request routing on client env helpers; use `/api/studio` `domainApiModeEnabled`.
- Do not add new generic local `openclaw.json` mutation wrappers for runtime agent-management flows; if a local-only integration is introduced, keep any local writes narrowly scoped and well tested.
- Do not store gateway tokens or secrets in client-side persistent storage.
- Do not add new global mutable state outside `AgentStoreProvider` for agent UI data.
- Do not silently swallow errors in API routes; always return actionable errors.
- Do not add heavy abstractions or frameworks unless there is clear evidence of need.
## Future-proofing notes
- If multi-user support becomes a goal, replace the settings file with a DB-backed service and introduce authentication at the API boundary.
- If gateway protocol evolves, isolate changes within `src/lib/gateway` and keep UI call sites stable.
## Guardrails
- Do not reintroduce browser direct gateway transport.
- Do not add new `/api/gateway/*` routes.
- Keep gateway method allowlist explicit in `openclaw-adapter.ts`.
- Keep settings token redaction server-side.
- Keep migrations additive for `runtime.db`.
+7 -5
View File
@@ -78,13 +78,11 @@ Notes:
## How It Connects (Mental Model)
In domain API mode (default), there are **two primary paths**:
OpenClaw Studio now runs one runtime architecture with **two primary paths**:
1. Browser -> Studio: HTTP + SSE (`/api/runtime/*`, `/api/intents/*`, `/api/runtime/stream`)
2. Studio -> Gateway (upstream): one server-owned WebSocket opened by the Studio Node process
The legacy browser WebSocket bridge (`/api/gateway/ws`) is still available for compatibility/diagnostics when domain mode is disabled.
This is why `ws://localhost:18789` means “gateway on the Studio host”, not “gateway on your phone”.
## Install from source (advanced)
@@ -103,7 +101,7 @@ Paths and key settings:
- Studio settings: `~/.openclaw/openclaw-studio/settings.json`
- Control-plane runtime DB: `~/.openclaw/openclaw-studio/runtime.db`
- Default gateway URL: `ws://localhost:18789` (override via Studio Settings or `NEXT_PUBLIC_GATEWAY_URL`)
- Domain API mode toggle: `STUDIO_DOMAIN_API_MODE` (server) or `NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE` fallback. The UI reads the effective value from `/api/studio` (`domainApiModeEnabled`) and uses that server-reported value for runtime routing.
- Domain API mode: always enabled. Studio runs on the server-owned control-plane architecture.
- `STUDIO_ACCESS_TOKEN`: required when binding Studio to a public host (`HOST=0.0.0.0`, `HOST=::`, or non-loopback hostnames/IPs); optional for loopback-only binds (`127.0.0.1`, `::1`, `localhost`)
Startup guard behavior:
@@ -120,7 +118,7 @@ See `docs/ui-guide.md` for UI workflows (agent creation, cron jobs, exec approva
## PI + chat streaming
See `docs/pi-chat-streaming.md` for how Studio bridges browser WebSocket traffic to the upstream Gateway, how runtime streaming arrives (`chat`/`agent` events), and how the chat UI renders tool calls, thinking traces, and final transcript lines.
See `docs/pi-chat-streaming.md` for how Studio streams runtime events over domain SSE (`/api/runtime/stream`), applies replay/history, and renders tool calls, thinking traces, and final transcript lines.
## Permissions + sandboxing
@@ -141,6 +139,10 @@ If the UI loads but “Connect” fails, its usually Studio->Gateway:
If startup fails with `better_sqlite3.node` / `NODE_MODULE_VERSION` mismatch:
- Run `npm run verify:native-runtime:repair`
- Confirm `node` and `npm` point at the same runtime before launching Studio:
- `node -v && node -p "process.versions.modules"`
- `which node && which npm`
- If they differ (for example Homebrew `npm` + `nvm` `node`), run `nvm use` in that terminal first.
- If it still fails, run:
- `npm rebuild better-sqlite3`
- `npm install`
+203
View File
@@ -0,0 +1,203 @@
# Control Plane Cutover Audit
Date: 2026-03-02
Status: fresh-eyes first-principles inventory (pass 2)
## Objective
Identify every remaining legacy path where browser code is still coupled to gateway protocol or gateway transport semantics, so we can remove those connections in one final cutover.
## First-Principles Boundary
Target boundary:
- Browser: Studio domain APIs only (`/api/runtime/*`, `/api/intents/*`, SSE stream).
- Server: sole owner of upstream OpenClaw gateway WebSocket and method contracts.
A path is legacy if any browser-executed module does one of:
1. opens (or auto-opens) gateway transport (`/api/gateway/ws`), or
2. issues gateway RPC semantics (`client.call("...")`), directly or through gateway helper wrappers.
## Re-scan Method
Commands used:
```bash
rg -n "GatewayClient|GatewayBrowserClient|useGatewayConnection|resolveStudioProxyGatewayUrl|/api/gateway/ws" src server scripts docs README.md ARCHITECTURE.md tests
rg -n "client\.call\(" src --glob '!**/*.test.*'
rg -n "new WebSocket\(|WebSocket\(" src server --glob '!**/*.test.*'
rg -n "/api/gateway/" src --glob '!**/*.test.*'
```
Current measured footprint:
- `29` non-test `src` files import from `@/lib/gateway/GatewayClient`.
- `23` non-test `client.call(...)` call-sites remain in `src`.
- `8` non-test files currently contain direct `client.call(...)` executions.
- `41` test files still reference legacy gateway client/proxy surfaces.
## A) Hard Blockers: Browser Transport Still Exists
These files keep the browser->gateway WS architecture alive:
- `src/app/page.tsx:230`
- App root still calls `useGatewayConnection(...)`.
- `src/lib/gateway/GatewayClient.ts:573`
- `connect(...)` uses `resolveStudioProxyGatewayUrl()`.
- `src/lib/gateway/GatewayClient.ts:590-596`
- auto-connect effect still runs after settings load.
- `src/lib/gateway/proxy-url.ts:1-5`
- browser WS URL builder for `/api/gateway/ws`.
- `src/lib/gateway/openclaw/GatewayBrowserClient.ts:421`
- browser `new WebSocket(...)`.
- `server/index.js:55-66`
- `/api/gateway/ws` upgrade wiring.
- `server/gateway-proxy.js:88-293`
- bridge from browser WS to upstream gateway WS.
Important drift from architecture intent:
- `useGatewayConnection` currently has no domain-mode suppression guard around auto-connect/retry paths; `domainApiModeEnabled` is loaded and returned, but not used to prevent transport startup in this file.
## B) Active Browser Gateway RPC Paths (Not Just Types)
### 1) Settings: config/models via browser RPC
- `src/features/agents/operations/useGatewayConfigSyncController.ts:68` `config.get`
- `src/features/agents/operations/useGatewayConfigSyncController.ts:138` `config.get`
- `src/features/agents/operations/useGatewayConfigSyncController.ts:149-151` `models.list`
### 2) Settings: skills + cron via browser RPC helpers
- `src/features/agents/operations/useAgentSettingsMutationController.ts:163` `skills.status`
- `src/features/agents/operations/useAgentSettingsMutationController.ts:232` `cron.list`
- `src/features/agents/operations/useAgentSettingsMutationController.ts:531` `cron.run`
- `src/features/agents/operations/useAgentSettingsMutationController.ts:562` `cron.remove`
- `src/features/agents/operations/useAgentSettingsMutationController.ts:730-734` skills allowlist write
- `src/features/agents/operations/useAgentSettingsMutationController.ts:776-779` skills allowlist read
- `src/features/agents/operations/useAgentSettingsMutationController.ts:817-822` skills allowlist write
- `src/features/agents/operations/useAgentSettingsMutationController.ts:913-917` `skills.install`
- `src/features/agents/operations/useAgentSettingsMutationController.ts:1003-1006` `skills.update`
- `src/features/agents/operations/useAgentSettingsMutationController.ts:1026-1029` `skills.update`
### 3) Personality files via browser RPC
- `src/features/agents/components/AgentInspectPanels.tsx:1251` `readGatewayAgentFile(...)`
- `src/features/agents/components/AgentInspectPanels.tsx:1293` `writeGatewayAgentFile(...)`
- `src/lib/gateway/agentFiles.ts:22` `agents.files.get`
- `src/lib/gateway/agentFiles.ts:41` `agents.files.set`
### 4) Latest-update enrichment via browser RPC
- `src/app/page.tsx:456-457` wires `client.call(...)` and cron list helper into latest-update op.
- `src/features/agents/operations/specialLatestUpdateOperation.ts:88-93` `sessions.list`
- `src/features/agents/operations/specialLatestUpdateOperation.ts:108-111` `chat.history`
## C) Legacy Fallback Branches Still Present in Runtime Paths
These are mode-gated but keep legacy behavior implemented and reachable:
- `src/features/agents/operations/runtimeWriteTransport.ts:97-271`
- fallback `client.call(...)` branches for `chat.send`, `chat.abort`, `sessions.reset`, `exec.approval.resolve`, `agent.wait`, plus legacy create/rename/delete helpers.
- `src/features/agents/operations/studioBootstrapOperation.ts:48-55`
- non-domain fleet hydration via gateway.
- `src/features/agents/operations/agentFleetHydration.ts:78-153`
- gateway reads: `config.get`, `exec.approvals.get`, `agents.list`, `sessions.list`, `status`, `sessions.preview`.
- `src/features/agents/operations/useRuntimeSyncController.ts:133-343`
- non-domain summary/history/reconcile/gap handling via gateway RPC.
- `src/features/agents/operations/agentPermissionsOperation.ts:207-323`
- legacy config/session/approvals mutation path.
## D) Gateway-Coupled Shared Modules That Must Go in Final Cutover
These wrappers encode gateway methods and keep browser feature code coupled:
- `src/lib/gateway/agentConfig.ts`
- `config.get`, `config.patch`, `config.set`, `agents.create`, `agents.update`, `agents.delete`, `status`.
- `src/lib/gateway/execApprovals.ts`
- `exec.approvals.get`, `exec.approvals.set`.
- `src/lib/gateway/agentFiles.ts`
- `agents.files.get`, `agents.files.set`.
- `src/lib/gateway/gatewayReloadMode.ts`
- legacy config writes.
- `src/lib/cron/types.ts`
- `cron.list`, `cron.add`, `cron.run`, `cron.remove`.
- `src/lib/skills/types.ts`
- `skills.status`, `skills.install`, `skills.update`.
## E) Legacy Namespace Routes (Not WS, but Cleanup Targets)
These are server routes but still under legacy `/api/gateway/*` naming and browser callers:
- Callers:
- `src/lib/text/media-markdown.ts:17` -> `/api/gateway/media`
- `src/features/agents/operations/deleteAgentOperation.ts:140,151` -> `/api/gateway/agent-state`
- `src/lib/skills/remove.ts:23` -> `/api/gateway/skills/remove`
- Routes:
- `src/app/api/gateway/media/route.ts`
- `src/app/api/gateway/agent-state/route.ts`
- `src/app/api/gateway/skills/remove/route.ts`
Note:
- These are not direct browser WS transport, but they should be renamed/re-homed during final legacy cleanup.
## F) Documentation + Test Drag
### Docs still describing legacy browser gateway mode
- `README.md:86`
- `ARCHITECTURE.md` (multiple sections describing `/api/gateway/ws` and `GatewayClient` as active architecture)
- `docs/pi-chat-streaming.md` (legacy browser gateway transport narrative)
- `docs/permissions-sandboxing.md` (GatewayClient references)
### Tests tied to legacy stack
`41` test files currently reference `GatewayClient`, `GatewayBrowserClient`, `/api/gateway/ws`, or `/api/gateway/*` routes.
Representative sets:
- transport/proxy: `tests/unit/gatewayProxy.test.ts`, `tests/unit/gatewayBrowserClient.test.ts`, `tests/unit/useGatewayConnection.test.ts`
- gateway wrappers: `tests/unit/gatewayAgentOverrides.test.ts`, `tests/unit/gatewayExecApprovals.test.ts`, `tests/unit/cronGatewayClient.test.ts`, `tests/unit/skillsGatewayClient.test.ts`
- legacy routes: `tests/unit/gatewayMediaRoute.test.ts`, `tests/unit/agentStateRoute.test.ts`, `tests/unit/skillsRemoveRoute.test.ts`
## Complete Removal Checklist (File Groups)
### 1) Remove browser transport stack
- `src/lib/gateway/GatewayClient.ts`
- `src/lib/gateway/openclaw/GatewayBrowserClient.ts`
- `src/lib/gateway/proxy-url.ts`
- `server/gateway-proxy.js`
- `/api/gateway/ws` wiring in `server/index.js`
### 2) Replace active browser RPC UI surfaces
- `src/features/agents/operations/useGatewayConfigSyncController.ts`
- `src/features/agents/operations/useAgentSettingsMutationController.ts`
- `src/features/agents/components/AgentInspectPanels.tsx`
- `src/features/agents/operations/specialLatestUpdateOperation.ts`
- `src/app/page.tsx` (latest-update gateway wiring + connection hook dependency)
### 3) Remove fallback legacy branches
- `src/features/agents/operations/runtimeWriteTransport.ts`
- `src/features/agents/operations/studioBootstrapOperation.ts`
- `src/features/agents/operations/agentFleetHydration.ts`
- `src/features/agents/operations/useRuntimeSyncController.ts`
- `src/features/agents/operations/agentPermissionsOperation.ts`
### 4) Remove gateway method wrapper modules
- `src/lib/gateway/agentConfig.ts`
- `src/lib/gateway/agentFiles.ts`
- `src/lib/gateway/execApprovals.ts`
- `src/lib/gateway/gatewayReloadMode.ts`
- `src/lib/cron/types.ts` (legacy RPC portions)
- `src/lib/skills/types.ts` (legacy RPC portions)
### 5) Re-home `/api/gateway/*` route namespace
- `src/app/api/gateway/media/route.ts`
- `src/app/api/gateway/agent-state/route.ts`
- `src/app/api/gateway/skills/remove/route.ts`
- update callers in `src/lib/text/media-markdown.ts`, `src/features/agents/operations/deleteAgentOperation.ts`, `src/lib/skills/remove.ts`
## Exit Criteria (Migration Actually Complete)
1. No browser module imports or uses `useGatewayConnection` / `GatewayClient` / `GatewayBrowserClient`.
2. No browser runtime path executes `client.call(...)` for gateway methods.
3. `server/index.js` no longer handles `/api/gateway/ws` upgrades.
4. `server/gateway-proxy.js` is deleted.
5. `/api/gateway/*` routes are removed or fully re-homed.
6. Docs and tests no longer describe legacy browser-gateway mode as a supported operational path.
+31 -373
View File
@@ -1,373 +1,31 @@
# PI + Chat Streaming (Studio Side)
This document exists to onboard coding agents quickly when debugging chat issues in OpenClaw Studio.
Scope:
- Describes how Studio connects to the OpenClaw Gateway, how runtime streaming arrives over WebSockets, and how the UI renders it.
- Treats **PI** as “the coding agent running behind the Gateway” (an OpenClaw agent). Studio does not implement PI logic; it displays and controls the Gateway session.
Non-scope:
- PI internals and model/tool execution details. Those live in the OpenClaw repository and the Gateway implementation.
## Key Files (Start Here)
- Studio server entry + upgrade wiring: `server/index.js`
- Browser WS bridge to upstream gateway: `server/gateway-proxy.js`
- Browser WS URL (always same-origin `/api/gateway/ws`): `src/lib/gateway/proxy-url.ts`
- Browser gateway protocol client (vendored): `src/lib/gateway/openclaw/GatewayBrowserClient.ts`
- Studio gateway wrapper + connect policy: `src/lib/gateway/GatewayClient.ts`
- Runtime stream classification and merge helpers: `src/features/agents/state/runtimeEventBridge.ts`
- Runtime event executor (streaming -> state -> transcript lines): `src/features/agents/state/gatewayRuntimeEventHandler.ts`
- Chat rendering: `src/features/agents/components/AgentChatPanel.tsx`, `src/features/agents/components/chatItems.ts`
- Message parsing (text/thinking/tool markers): `src/lib/text/message-extract.ts`
- History sync + transcript merge: `src/features/agents/operations/historySyncOperation.ts`, `src/features/agents/state/transcript.ts`
## Relationship To OpenClaw (Whats Vendored Here)
Studio vendors the browser Gateway client used to speak the Gateway protocol:
- Vendored client: `src/lib/gateway/openclaw/GatewayBrowserClient.ts`
- Sync script: `scripts/sync-openclaw-gateway-client.ts`
- Current sync source path used by that script: `~/openclaw/ui/src/ui/gateway.ts`
Important:
- Studio syncs `GatewayBrowserClient.ts` from `~/openclaw` via the sync script above.
- If protocol mismatch is suspected, first verify the sync source file and the upstream Gateway runtime/protocol files are aligned.
If a protocol mismatch is suspected (missing event fields, renamed streams, different error codes), start by checking whether Studios vendored client is in sync with the Gateway version youre running.
## Upstream Source Of Truth (OpenClaw)
For chat streaming behavior, these upstream files are authoritative:
- `~/openclaw/src/gateway/protocol/schema/logs-chat.ts` (`chat.send`, `chat.history`, and chat event schema)
- `~/openclaw/src/gateway/server-methods/chat.ts` (`chat.send` ack + idempotency, `chat.history` payload shaping/sanitization)
- `~/openclaw/src/gateway/server-chat.ts` (`agent` event fanout and synthetic `chat` delta/final bridging)
- `~/openclaw/src/agents/pi-embedded-subscribe.ts` and handlers (`assistant`/`tool`/`lifecycle` stream emission)
When updating this doc, verify behavior against those files, not assumptions.
## Terminology
- Studio: this repo, a Next.js UI with a custom Node server.
- Gateway (upstream): the OpenClaw Gateway WebSocket server (default `ws://localhost:18789`).
- WS bridge / proxy: Studios server-side WebSocket that bridges the browser to the upstream Gateway.
- Frame: JSON message over WebSocket (request/response/event).
- Run: a single streamed execution identified by `runId`.
- Session: identified by `sessionKey` (Studio uses `agent:<agentId>:<mainKey>` for main sessions).
## High-Level Network Path
There are two separate WebSocket hops, plus a protocol-level `connect` request:
```mermaid
sequenceDiagram
participant B as Browser (Studio UI)
participant S as Studio server (WS proxy)
participant G as OpenClaw Gateway (upstream)
B->>S: WS connect /api/gateway/ws
B->>S: req(connect) (Gateway protocol frame)
S->>G: WS connect upstream (url from settings.json)
S->>G: req(connect) (injects token if missing)
G-->>S: res(connect)
S-->>B: res(connect)
G-->>S: event(chat/agent/presence/heartbeat)
S-->>B: event(...)
```
Files:
- WS proxy entrypoint: `server/index.js`
- WS proxy implementation: `server/gateway-proxy.js`
Notes:
- The browser never opens a WebSocket directly to the upstream Gateway URL. The browser always speaks to the Studio same-origin bridge at `/api/gateway/ws` (computed by `src/lib/gateway/proxy-url.ts`).
- The “upstream gateway URL” shown in Studio settings is used by the Studio server (the proxy) to open the upstream connection.
## End-To-End Flow (PI Run -> UI)
This is the “happy path” you want in your head when debugging:
1. User types in the chat composer and hits Send (`src/features/agents/components/AgentChatPanel.tsx`).
2. Studio calls `chat.send` with `sessionKey` and `idempotencyKey = runId` (`src/features/agents/operations/chatSendOperation.ts`).
3. Gateway runs the agent (PI) for that session.
4. While the run is executing, the Gateway may stream:
- `event: "agent"` frames for live partial output (`stream: "assistant"`), live thinking (`reason*`/`think*` streams), tool calls/results (`stream: "tool"`), and lifecycle (`stream: "lifecycle"`).
- `event: "chat"` frames for the chat message stream (`state: "delta" | "final" | ...`).
- Both streams can describe the same run progression from different layers (`agent` stream events and `chat` message events), so Studio must merge idempotently.
5. Studio merges those events into:
- live fields (`streamText`, `thinkingTrace`) via batched `queueLivePatch` (fast UI updates without committing to the transcript yet)
- committed transcript lines (`outputLines`) via `appendOutput` (final messages, tool lines, meta/timestamp, thinking trace)
6. The chat panel renders:
- historical transcript from `outputLines`
- an extra “live assistant” card at the bottom built from `streamText` + `thinkingTrace` while `status === "running"`.
The key wiring is in:
- Event subscription + dispatch: `src/app/page.tsx`
- Runtime event handler: `src/features/agents/state/gatewayRuntimeEventHandler.ts`
- Store reducer: `src/features/agents/state/store.tsx`
## Studio Settings (Where Gateway URL/Token Come From)
Studio persists Gateway connection settings on the Studio host (not in browser storage). The UI loads them into browser memory at runtime:
- `~/.openclaw/openclaw-studio/settings.json` (see `README.md` for the canonical location)
The WS proxy loads these settings server-side and opens the upstream connection.
Files:
- Settings file access (WS proxy): `server/studio-settings.js`
- Settings API route (browser -> server): `src/app/api/studio/route.ts`
- Client-side load/patch coordinator: `src/lib/studio/coordinator.ts`
- Settings storage + fallback behavior used by `/api/studio`: `src/lib/studio/settings-store.ts`
Connection note:
- In the browser, `useGatewayConnection()` stores the upstream URL/token in memory (loaded from `/api/studio`) but connects the WebSocket to Studio via `resolveStudioProxyGatewayUrl()`; the upstream URL is passed as `authScopeKey` (not as the WebSocket URL). See `src/lib/gateway/GatewayClient.ts`.
Token resolution note:
- The Studio server resolves an upstream token from `openclaw-studio/settings.json`, and if it is missing it may fall back to the local OpenClaw config in `openclaw.json` (token + port). This behavior exists in both the WS proxy path (`server/studio-settings.js`) and the `/api/studio` storage layer (`src/lib/studio/settings-store.ts`) and they should remain consistent.
- During `connect`, the WS proxy forwards browser-provided auth (`params.auth.token` or `params.device.signature`) as-is. It injects the host-resolved token only when browser auth is absent. `studio.gateway_token_missing` is returned only when neither browser auth nor host token is available.
## WebSocket Frame Shapes
Studio expects Gateway frames shaped like:
```json
{ "type": "req", "id": "uuid", "method": "connect", "params": { } }
{ "type": "res", "id": "uuid", "ok": true, "payload": { } }
{ "type": "res", "id": "uuid", "ok": false, "error": { "code": "…", "message": "…" } }
{ "type": "event", "event": "chat", "payload": { } }
```
Types live in:
- `src/lib/gateway/GatewayClient.ts`
### Connect handshake
The first *protocol frame* from the browser must be `req(connect)`. The WS proxy:
- Rejects non-`connect` frames until connected.
- Opens an upstream WS to the configured Gateway URL.
- Injects `auth.token` into the connect params if the connect frame does not already contain a token, and if it does not include a device signature.
- Returns `studio.gateway_token_missing` only when no browser auth is present and no host token can be resolved.
- Sets an `Origin` header for the upstream WebSocket derived from the upstream URL (and normalizes loopback hostnames to `localhost`).
Code:
- Connect enforcement + token injection: `server/gateway-proxy.js`
### Connect failures
On failure to load settings or open upstream, the proxy sends an error `res` for the connect request (when possible) and then closes the WS.
Important detail (how errors become actionable in the UI):
- The browser-side Gateway client (`src/lib/gateway/openclaw/GatewayBrowserClient.ts`) closes the WebSocket with close code `4008` and a reason like `connect failed: <CODE> <MESSAGE>` after it receives a failed `res(connect)`. `GatewayClient.connect()` parses that close into `GatewayResponseError(code, message)` for UI retry policy and user-facing errors.
- Separately, the proxy may also close with `1011` / `connect failed`; the “connect failed: …” close reason that the UI parses is produced by the browser client, not the proxy.
- WebSocket close reasons are truncated to 123 UTF-8 bytes in the browser client to avoid protocol errors on long messages.
Error codes used by the proxy include:
- `studio.gateway_url_missing`
- `studio.gateway_token_missing`
- `studio.gateway_url_invalid`
- `studio.settings_load_failed`
- `studio.upstream_error`
- `studio.upstream_closed`
## Reconnects And Retries
There are two layers of retry behavior:
- Transport reconnect (after a successful hello): the vendored browser client reconnects the browser->Studio WebSocket with backoff when it closes, and continues emitting events after reconnect. See `src/lib/gateway/openclaw/GatewayBrowserClient.ts`.
- Initial connect failure retry: when the initial `connect` handshake fails (for example bad token), `GatewayClient.connect()` tears down the vendored client and returns a rejected promise; `useGatewayConnection()` may schedule a limited re-attempt unless the error code is known non-retryable. See `resolveGatewayAutoRetryDelayMs` in `src/lib/gateway/GatewayClient.ts`.
## Studio Access Gate
When Studio is bound to a public host, `STUDIO_ACCESS_TOKEN` is required. For loopback-only binds, it remains optional. When enabled, Studio enforces a simple access gate:
- HTTP: blocks `/api/*` routes unless the correct cookie is present; you can set it once via `/?access_token=...`.
- WebSocket: blocks `/api/gateway/ws` upgrades unless the cookie is present.
Files:
- Gate implementation: `server/access-gate.js`
- Gate integration for WS upgrades: `server/index.js`
## Streaming: What the Gateway Sends and How Studio Uses It
Studio classifies gateway events by `event` name:
- `presence`, `heartbeat`: summary refresh triggers
- `chat`: runtime chat messages (delta/final)
- `agent`: runtime per-stream deltas (assistant/thinking/tool/lifecycle)
Code:
- Classification: `src/features/agents/state/runtimeEventBridge.ts`
- Execution: `src/features/agents/state/gatewayRuntimeEventHandler.ts`
## Live Fields vs Committed Transcript (Why Streaming Can “Look Weird”)
Studio intentionally separates:
- Live streaming UI: `AgentState.streamText` and `AgentState.thinkingTrace` are updated via `queueLivePatch`, which batches patches and coalesces multiple deltas before they hit React state (`src/app/page.tsx`).
- Committed transcript: `AgentState.outputLines` is appended via `appendOutput`. These are the lines that become the durable on-screen transcript and are later merged with `chat.history` results (`src/features/agents/state/store.tsx`).
This split is why you can see:
- “live” assistant output update rapidly at the bottom card during a run
- then a finalized assistant message (plus tool lines / thinking trace / meta timestamp) appear in the transcript on `final`
### `event: "chat"` payload
Studio treats `chat` events as the canonical “message” stream for transcript completion. Expected fields:
- `runId`
- `sessionKey`
- `state`: `delta | final | aborted | error`
- `message` (shape varies; Studio extracts text/thinking/tool metadata defensively)
Key behaviors (Studio-side):
- Ignores user/system roles for transcript append (but uses them for status/summary).
- User messages shown in the transcript are primarily from local optimistic send and from `chat.history` sync (not from runtime `chat` user-role events).
- On `final`, appends:
- a `[[meta]]{...}` line (timestamp and thinking duration when available)
- a `[[trace]]` thinking block when extracted
- tool call/result markdown lines when present
- the assistant text (if any)
- If a `final` assistant message arrives without an extractable thinking trace, Studio may request `chat.history` as recovery.
- `chat.send` is idempotency-keyed upstream and returns a started ack before async completion; this is why history reconciliation can race with runtime events and must be idempotent.
### `event: "agent"` payload
Studio uses `agent` events for live streaming and richer tool/lifecycle updates. Expected fields:
- `runId`
- `stream`: `assistant | tool | lifecycle | <reasoning stream>`
- `data`: record with `text`/`delta` and stream-specific keys
Stream handling (high-level):
- `assistant`: merges `data.delta` into a live `streamText` for the UI.
- reasoning stream (anything that is not `assistant`, `tool`, `lifecycle` and matches hints like `reason`/`think`/`analysis`/`trace`): merged into `thinkingTrace`.
- `tool`: formats tool call and tool result lines using `[[tool]]` and `[[tool-result]]`.
- `lifecycle`: start/end/error transitions; if a run reaches `end` without chat final events, Studio may flush the last streamed assistant text as a fallback final transcript entry.
Code:
- Runtime agent stream merge + append: `src/features/agents/state/gatewayRuntimeEventHandler.ts`
## How Chat UI Renders Streaming
Studio keeps an `outputLines: string[]` transcript per agent, plus live fields like `streamText` and `thinkingTrace`.
Rendering pipeline:
- `outputLines` contains:
- user messages as `> ...`
- assistant messages as raw markdown text
- tool call/results with prefixes `[[tool]]` and `[[tool-result]]`
- optional meta lines `[[meta]]{...}` for timestamps and thinking durations
- optional thinking trace lines `[[trace]] ...`
- The panel derives structured chat items from `outputLines` and (optionally) live streaming state.
- UI toggles that change rendering:
- `showThinkingTraces`: hides/shows `[[trace]]` thinking entries.
- `toolCallingEnabled`: when off, tool lines are hidden and some exec tool results may be shown as assistant text.
### Rendering contract
- Assistant markdown renders as assistant markdown. Studio does not wrap normal assistant markdown in a synthetic `Output` container.
- Tool cards render only from explicit marker lines: `[[tool]]` and `[[tool-result]]`.
- List-marker visibility comes from chat markdown styles in `src/app/styles/markdown.css`; stream parsing does not invent list bullets.
Files:
- Chat panel UI: `src/features/agents/components/AgentChatPanel.tsx`
- Transcript parsing into items: `src/features/agents/components/chatItems.ts`
- Message extraction helpers (text/thinking/tool parsing): `src/lib/text/message-extract.ts`
- Media line rewrite (images/audio/video rendered in markdown): `src/lib/text/media-markdown.ts`
## Sending Messages (Browser -> PI via Gateway)
Send path (high level):
- UI submits a message through `sendChatMessageViaStudio()` which:
- Sets agent state to running and clears live streams.
- Optionally resets local transcript state for `/new` or `/reset` (local UI behavior).
- Optimistically appends the user line (`> ...`) to the transcript.
- Ensures session settings are synced once via `sessions.patch` (model/thinking/exec settings) before first send.
- Calls `chat.send` with `idempotencyKey = runId` and `deliver: false`.
Stop path:
- UI calls `chat.abort` to stop an active run.
Files:
- Send operation: `src/features/agents/operations/chatSendOperation.ts`
- Session settings sync transport: `src/lib/gateway/GatewayClient.ts`
- Stop call site: `src/app/page.tsx`
## Post-Connect Side Effects (Local Gateway Only)
After a successful connection, Studio may mutate gateway config when the upstream gateway URL is local:
- It reads `config.get` and may write `config.set` to ensure `gateway.reload.mode` is `"hot"` for local Studio usage.
File:
- Reload mode enforcement: `src/lib/gateway/gatewayReloadMode.ts`
## Sequence Gaps (Dropped Events)
Gateway event frames may include `seq`. The vendored browser client tracks `seq` and reports gaps (`expected`, `received`) via `onGap`.
Studio behavior on gap:
- Logs a warning.
- Forces a summary snapshot refresh and reconciles running agents.
Files:
- Gap detection: `src/lib/gateway/openclaw/GatewayBrowserClient.ts`
- Gap handling: `src/app/page.tsx`
## History Sync (Recovery, Load More)
Studio can fetch history via `chat.history` and merge it into the transcript.
Key points:
- Studio intentionally treats gateway history as canonical for timestamps/final ordering.
- History merge is designed to avoid duplicates and reconcile local optimistic sends.
- History parsing intentionally skips some system-ish content (heartbeat prompts, restart sentinel messages, and UI metadata prefixes). See `buildHistoryLines()` in `src/features/agents/state/runtimeEventBridge.ts`.
- Transcript v2 can be toggled with `NEXT_PUBLIC_STUDIO_TRANSCRIPT_V2`.
- Transcript debug logs can be enabled with `NEXT_PUBLIC_STUDIO_TRANSCRIPT_DEBUG`.
Files:
- History operation: `src/features/agents/operations/historySyncOperation.ts`
- Transcript merge/sort primitives: `src/features/agents/state/transcript.ts`
## Exec Approvals In Chat (Related To “PI Runs”)
Some runs require exec approval. These are surfaced as in-chat cards and are handled separately from the `chat`/`agent` runtime stream.
Files:
- Event to pending-card state: `src/features/agents/approvals/execApprovalEvents.ts`
- Resolve operation: `src/features/agents/approvals/execApprovalResolveOperation.ts`
- Wiring (subscribe + render): `src/app/page.tsx`, `src/features/agents/components/AgentChatPanel.tsx`
## Media Rendering (Images From Agent Output)
If an agent outputs lines like:
- `MEDIA: /home/ubuntu/.openclaw/.../image.png`
Studio may render them inline:
1. UI rewrites eligible `MEDIA:` lines into markdown images (`![](/api/gateway/media?path=...)`) but avoids rewriting inside fenced code blocks.
2. The browser requests `/api/gateway/media`.
3. The API route reads the image either locally (only under `~/.openclaw`) or over SSH for remote gateways, and returns the bytes with the correct `Content-Type`.
Files:
- Rewrite helper: `src/lib/text/media-markdown.ts`
- Media API route: `src/app/api/gateway/media/route.ts`
- SSH helper + env vars (`OPENCLAW_GATEWAY_SSH_TARGET`, `OPENCLAW_GATEWAY_SSH_USER`): `src/lib/ssh/gateway-host.ts`
## Debugging Checklist (When Chat “Feels Buggy”)
Start with the hop where symptoms appear.
WS bridge / connectivity:
- Studio server logs (proxy): `server/gateway-proxy.js`
- Common failures: wrong `ws://` vs `wss://`, missing token, gateway closed, upstream TLS mismatch
Streaming correctness (missing/duplicated output):
- Event classification + runtime stream merge: `src/features/agents/state/gatewayRuntimeEventHandler.ts`
- Text/thinking/tool extraction quirks: `src/lib/text/message-extract.ts`
- UI item derivation and collapsing rules: `src/features/agents/components/chatItems.ts`
- Dedupe of tool lines per run + closed-run ignore window: `src/features/agents/state/gatewayRuntimeEventHandler.ts`
History and ordering issues:
- `chat.history` merge logic and dedupe: `src/features/agents/operations/historySyncOperation.ts`
- Transcript entry ordering/fingerprints: `src/features/agents/state/transcript.ts`
Media not rendering:
- `MEDIA:` rewrite behavior and code-fence skipping: `src/lib/text/media-markdown.ts`
- Image fetch route behavior (local vs SSH, allowlisted extensions, size limits): `src/app/api/gateway/media/route.ts`
If you need Gateway-side observability:
- Capture the exact `connect` settings used by Studio (URL + token are stored server-side in the Studio settings file).
- Inspect Gateway logs on the Gateway host using your environments service/log tooling.
# PI Chat Streaming
## Current transport model
PI/chat runtime streaming is server-owned control plane only.
- Browser subscribes to `GET /api/runtime/stream` (SSE).
- Studio server maintains the upstream gateway websocket.
- Browser never opens a direct gateway websocket.
## Event flow
1. User sends message via `POST /api/intents/chat-send`.
2. Server forwards intent through control-plane adapter (`chat.send`).
3. Gateway emits runtime events; adapter projects them to outbox.
4. `/api/runtime/stream` emits ordered `gateway.event` frames with monotonic outbox ids.
5. Browser ingests events through existing runtime/approval handlers.
## Replay and resume
- If client reconnects with `Last-Event-ID`, stream replays forward from that id.
- If client connects fresh (no `Last-Event-ID`), stream replays a recent tail window from outbox head.
- Live subscription and replay are sequenced to avoid replay/live gaps and duplicate terminal effects.
## History backfill
- `GET /api/runtime/agents/[agentId]/history?limit=<n>&beforeOutboxId=<id>`
- Returns newest window first and cursor metadata:
- `hasMore`
- `nextBeforeOutboxId`
- Browser applies history entries through the same event pipeline as live stream and dedupes outbox ids.
## Freshness/degraded behavior
- Runtime reads expose freshness metadata when gateway is unavailable.
- Projection-backed data can still render while writes fail fast with deterministic gateway-unavailable errors.
+4 -10
View File
@@ -9,7 +9,6 @@
"version": "0.1.0",
"dependencies": {
"@multiavatar/multiavatar": "github:multiavatar/Multiavatar",
"@noble/ed25519": "^3.0.0",
"@vercel/otel": "^2.1.0",
"better-sqlite3": "^12.6.2",
"clsx": "^2.1.1",
@@ -40,6 +39,9 @@
"tailwindcss": "^4",
"typescript": "^5",
"vitest": "^4.0.18"
},
"engines": {
"node": ">=20.9.0"
}
},
"node_modules/@acemir/cssom": {
@@ -1873,15 +1875,6 @@
"node": ">= 10"
}
},
"node_modules/@noble/ed25519": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@noble/ed25519/-/ed25519-3.0.0.tgz",
"integrity": "sha512-QyteqMNm0GLqfa5SoYbSC3+Pvykwpn95Zgth4MFVSMKBB75ELl9tX1LAVsN4c3HXOrakHsF2gL4zWDAYCcsnzg==",
"license": "MIT",
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -5633,6 +5626,7 @@
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
+3 -5
View File
@@ -8,16 +8,15 @@
"scripts": {
"verify:native-runtime:check": "node scripts/verify-native-runtime.mjs --check",
"verify:native-runtime:repair": "node scripts/verify-native-runtime.mjs --repair",
"predev": "npm run verify:native-runtime:repair",
"predev:turbo": "npm run verify:native-runtime:repair",
"prestart": "npm run verify:native-runtime:check",
"predev": "node scripts/verify-native-runtime.mjs --repair",
"predev:turbo": "node scripts/verify-native-runtime.mjs --repair",
"prestart": "node scripts/verify-native-runtime.mjs --check",
"dev": "node server/index.js --dev",
"dev:turbo": "node server/index.js --dev",
"build": "next build",
"start": "node server/index.js",
"lint": "eslint .",
"cleanup:ux-artifacts": "node scripts/cleanup-ux-artifacts.mjs",
"sync:gateway-client": "node scripts/sync-openclaw-gateway-client.ts",
"studio:setup": "node scripts/studio-setup.js",
"smoke:dev-server": "node scripts/smoke-dev-server.mjs",
"typecheck": "tsc --noEmit",
@@ -27,7 +26,6 @@
},
"dependencies": {
"@multiavatar/multiavatar": "github:multiavatar/Multiavatar",
"@noble/ed25519": "^3.0.0",
"@vercel/otel": "^2.1.0",
"better-sqlite3": "^12.6.2",
"clsx": "^2.1.1",
-34
View File
@@ -1,34 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const repoRoot = process.cwd();
const sourcePath = path.join(os.homedir(), "openclaw", "ui", "src", "ui", "gateway.ts");
const destPath = path.join(
repoRoot,
"src",
"lib",
"gateway",
"openclaw",
"GatewayBrowserClient.ts"
);
if (!fs.existsSync(sourcePath)) {
console.error(`Missing upstream gateway client at ${sourcePath}.`);
process.exit(1);
}
let contents = fs.readFileSync(sourcePath, "utf8");
contents = contents
.replace(
/from "\.\.\/\.\.\/\.\.\/src\/gateway\/protocol\/client-info\.js";/g,
'from "./client-info";'
)
.replace(
/from "\.\.\/\.\.\/\.\.\/src\/gateway\/device-auth\.js";/g,
'from "./device-auth-payload";'
);
fs.mkdirSync(path.dirname(destPath), { recursive: true });
fs.writeFileSync(destPath, contents, "utf8");
console.log(`Synced gateway client to ${destPath}.`);
+66 -4
View File
@@ -1,15 +1,49 @@
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
const require = createRequire(import.meta.url);
const mode = process.argv.includes("--repair") ? "repair" : "check";
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
const npmExecPath = process.env.npm_execpath || "";
const bundledNpmCliPath = path.resolve(
path.dirname(process.execPath),
"..",
"lib",
"node_modules",
"npm",
"bin",
"npm-cli.js"
);
const log = (message) => {
console.info(`[native-runtime] ${message}`);
};
const resolvePathEnvKey = () => {
for (const key of Object.keys(process.env)) {
if (key.toLowerCase() === "path") return key;
}
return "PATH";
};
const resolveSpawnEnv = () => {
const pathKey = resolvePathEnvKey();
const pathDelimiter = process.platform === "win32" ? ";" : ":";
const nodeBinDir = path.dirname(process.execPath);
const existingPath = process.env[pathKey] || "";
const prefixedPath = existingPath
? `${nodeBinDir}${pathDelimiter}${existingPath}`
: nodeBinDir;
return {
...process.env,
[pathKey]: prefixedPath,
npm_config_scripts_prepend_node_path: "true",
};
};
const getErrorCode = (error) => {
if (!error || typeof error !== "object" || Array.isArray(error)) return "";
const code = error.code;
@@ -47,7 +81,10 @@ const printRemediation = () => {
const verifyLoad = () => {
try {
require("better-sqlite3");
const BetterSqlite3 = require("better-sqlite3");
const db = new BetterSqlite3(":memory:");
db.prepare("SELECT 1").get();
db.close();
return { ok: true };
} catch (error) {
return {
@@ -59,15 +96,40 @@ const verifyLoad = () => {
};
const rebuildBetterSqlite = () => {
const result = spawnSync(npmCommand, ["rebuild", "better-sqlite3"], {
const spawnEnv = resolveSpawnEnv();
if (fs.existsSync(bundledNpmCliPath)) {
const viaBundledNpm = spawnSync(
process.execPath,
[bundledNpmCliPath, "rebuild", "better-sqlite3"],
{
stdio: "inherit",
env: spawnEnv,
}
);
if (viaBundledNpm.status === 0) return true;
}
if (npmExecPath.trim()) {
const viaExecPath = spawnSync(
process.execPath,
[npmExecPath, "rebuild", "better-sqlite3"],
{
stdio: "inherit",
env: spawnEnv,
}
);
if (viaExecPath.status === 0) return true;
}
const viaPath = spawnSync(npmCommand, ["rebuild", "better-sqlite3"], {
stdio: "inherit",
env: process.env,
env: spawnEnv,
});
return result.status === 0;
return viaPath.status === 0;
};
log(`mode=${mode}`);
log(`node=${process.version} abi=${process.versions.modules}`);
log(`node_exec=${process.execPath}`);
const firstPass = verifyLoad();
if (firstPass.ok) {
-295
View File
@@ -1,295 +0,0 @@
const { WebSocket, WebSocketServer } = require("ws");
const buildErrorResponse = (id, code, message) => {
return {
type: "res",
id,
ok: false,
error: { code, message },
};
};
const isObject = (value) => Boolean(value && typeof value === "object");
const safeJsonParse = (raw) => {
try {
return JSON.parse(raw);
} catch {
return null;
}
};
const resolvePathname = (url) => {
const raw = typeof url === "string" ? url : "";
const idx = raw.indexOf("?");
return (idx === -1 ? raw : raw.slice(0, idx)) || "/";
};
const injectAuthToken = (params, token) => {
const next = isObject(params) ? { ...params } : {};
const auth = isObject(next.auth) ? { ...next.auth } : {};
auth.token = token;
next.auth = auth;
return next;
};
const resolveOriginForUpstream = (upstreamUrl) => {
const url = new URL(upstreamUrl);
const proto = url.protocol === "wss:" ? "https:" : "http:";
const hostname =
url.hostname === "127.0.0.1" || url.hostname === "::1" || url.hostname === "0.0.0.0"
? "localhost"
: url.hostname;
const host = url.port ? `${hostname}:${url.port}` : hostname;
return `${proto}//${host}`;
};
const hasNonEmptyToken = (params) => {
const raw = params && isObject(params) && isObject(params.auth) ? params.auth.token : "";
return typeof raw === "string" && raw.trim().length > 0;
};
const hasNonEmptyPassword = (params) => {
const raw = params && isObject(params) && isObject(params.auth) ? params.auth.password : "";
return typeof raw === "string" && raw.trim().length > 0;
};
const hasNonEmptyDeviceToken = (params) => {
const raw = params && isObject(params) && isObject(params.auth) ? params.auth.deviceToken : "";
return typeof raw === "string" && raw.trim().length > 0;
};
const hasCompleteDeviceAuth = (params) => {
const device = params && isObject(params) && isObject(params.device) ? params.device : null;
if (!device) {
return false;
}
const id = typeof device.id === "string" ? device.id.trim() : "";
const publicKey = typeof device.publicKey === "string" ? device.publicKey.trim() : "";
const signature = typeof device.signature === "string" ? device.signature.trim() : "";
const nonce = typeof device.nonce === "string" ? device.nonce.trim() : "";
const signedAt = device.signedAt;
return (
id.length > 0 &&
publicKey.length > 0 &&
signature.length > 0 &&
nonce.length > 0 &&
Number.isFinite(signedAt) &&
signedAt >= 0
);
};
const isExpectedCloseBeforeOpenError = (error) => {
if (!(error instanceof Error)) return false;
const message = error.message.toLowerCase();
return message.includes("closed before the connection was established");
};
function createGatewayProxy(options) {
const {
loadUpstreamSettings,
allowWs = (req) => resolvePathname(req.url) === "/api/gateway/ws",
log = () => {},
logError = (msg, err) => console.error(msg, err),
createUpstreamWebSocket = (url, wsOptions) => new WebSocket(url, wsOptions),
} = options || {};
if (typeof loadUpstreamSettings !== "function") {
throw new Error("createGatewayProxy requires loadUpstreamSettings().");
}
const wss = new WebSocketServer({ noServer: true });
wss.on("connection", (browserWs) => {
let upstreamWs = null;
let upstreamReady = false;
let connectRequestId = null;
let connectResponseSent = false;
let closed = false;
const closeBoth = (code, reason) => {
if (closed) return;
closed = true;
try {
browserWs.close(code, reason);
} catch {}
try {
upstreamWs?.close(code, reason);
} catch {}
};
const sendToBrowser = (frame) => {
if (browserWs.readyState !== WebSocket.OPEN) return;
browserWs.send(JSON.stringify(frame));
};
const sendConnectError = (code, message) => {
if (connectRequestId && !connectResponseSent) {
connectResponseSent = true;
sendToBrowser(buildErrorResponse(connectRequestId, code, message));
}
closeBoth(1011, "connect failed");
};
browserWs.on("message", async (raw) => {
const parsed = safeJsonParse(String(raw ?? ""));
if (!parsed || !isObject(parsed)) {
closeBoth(1003, "invalid json");
return;
}
if (!upstreamWs) {
if (parsed.type !== "req" || parsed.method !== "connect") {
closeBoth(1008, "connect required");
return;
}
const id = typeof parsed.id === "string" ? parsed.id : "";
if (!id) {
closeBoth(1008, "connect id required");
return;
}
connectRequestId = id;
const browserHasAuth =
hasNonEmptyToken(parsed.params) ||
hasNonEmptyPassword(parsed.params) ||
hasNonEmptyDeviceToken(parsed.params) ||
hasCompleteDeviceAuth(parsed.params);
let upstreamUrl = "";
let upstreamToken = "";
try {
const settings = await loadUpstreamSettings();
upstreamUrl = typeof settings?.url === "string" ? settings.url.trim() : "";
upstreamToken = typeof settings?.token === "string" ? settings.token.trim() : "";
} catch (err) {
logError("Failed to load upstream gateway settings.", err);
sendConnectError("studio.settings_load_failed", "Failed to load Studio gateway settings.");
return;
}
if (!upstreamUrl) {
sendConnectError(
"studio.gateway_url_missing",
"Upstream gateway URL is not configured on the Studio host."
);
return;
}
if (!upstreamToken && !browserHasAuth) {
sendConnectError(
"studio.gateway_token_missing",
"Upstream gateway token is not configured on the Studio host."
);
return;
}
let upstreamOrigin = "";
try {
upstreamOrigin = resolveOriginForUpstream(upstreamUrl);
} catch {
sendConnectError(
"studio.gateway_url_invalid",
"Upstream gateway URL is invalid on the Studio host."
);
return;
}
try {
upstreamWs = createUpstreamWebSocket(upstreamUrl, { origin: upstreamOrigin });
} catch (err) {
logError("Upstream gateway WebSocket creation failed.", err);
sendConnectError(
"studio.upstream_error",
"Failed to connect to upstream gateway WebSocket."
);
return;
}
upstreamWs.on("open", () => {
upstreamReady = true;
if (browserHasAuth) {
upstreamWs.send(JSON.stringify(parsed));
return;
}
const connectFrame = {
...parsed,
params: injectAuthToken(parsed.params, upstreamToken),
};
upstreamWs.send(JSON.stringify(connectFrame));
});
upstreamWs.on("message", (upRaw) => {
const upParsed = safeJsonParse(String(upRaw ?? ""));
if (upParsed && isObject(upParsed) && upParsed.type === "res") {
const resId = typeof upParsed.id === "string" ? upParsed.id : "";
if (resId && connectRequestId && resId === connectRequestId) {
connectResponseSent = true;
}
}
if (browserWs.readyState === WebSocket.OPEN) {
browserWs.send(String(upRaw ?? ""));
}
});
upstreamWs.on("close", (ev) => {
const reason = typeof ev?.reason === "string" ? ev.reason : "";
if (!connectResponseSent) {
sendToBrowser(
buildErrorResponse(
connectRequestId,
"studio.upstream_closed",
`Upstream gateway closed (${ev.code}): ${reason}`
)
);
}
closeBoth(1012, "upstream closed");
});
upstreamWs.on("error", (err) => {
if (isExpectedCloseBeforeOpenError(err) && closed) {
log("Suppressed upstream close-before-open race.");
return;
}
logError("Upstream gateway WebSocket error.", err);
sendConnectError(
"studio.upstream_error",
"Failed to connect to upstream gateway WebSocket."
);
});
log("proxy connected");
return;
}
if (!upstreamReady || upstreamWs.readyState !== WebSocket.OPEN) {
closeBoth(1013, "upstream not ready");
return;
}
upstreamWs.send(JSON.stringify(parsed));
});
browserWs.on("close", () => {
closeBoth(1000, "client closed");
});
browserWs.on("error", (err) => {
logError("Browser WebSocket error.", err);
closeBoth(1011, "client error");
});
});
const handleUpgrade = (req, socket, head) => {
if (!allowWs(req)) {
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req);
});
};
return { wss, handleUpgrade };
}
module.exports = { createGatewayProxy };
+16 -41
View File
@@ -2,12 +2,12 @@ process.env.WS_NO_BUFFER_UTIL = process.env.WS_NO_BUFFER_UTIL || "1";
process.env.WS_NO_UTF_8_VALIDATE = process.env.WS_NO_UTF_8_VALIDATE || "1";
const http = require("node:http");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const next = require("next");
const { createAccessGate } = require("./access-gate");
const { createGatewayProxy } = require("./gateway-proxy");
const { assertPublicHostAllowed, resolveHosts } = require("./network-policy");
const { loadUpstreamGatewaySettings } = require("./studio-settings");
const resolvePort = () => {
const raw = process.env.PORT?.trim() || "3000";
@@ -16,14 +16,24 @@ const resolvePort = () => {
return port;
};
const resolvePathname = (url) => {
const raw = typeof url === "string" ? url : "";
const idx = raw.indexOf("?");
return (idx === -1 ? raw : raw.slice(0, idx)) || "/";
const verifyNativeRuntime = (dev) => {
if (process.env.OPENCLAW_SKIP_NATIVE_RUNTIME_VERIFY === "1") return;
const scriptPath = path.resolve(__dirname, "..", "scripts", "verify-native-runtime.mjs");
const modeArg = dev ? "--repair" : "--check";
const result = spawnSync(process.execPath, [scriptPath, modeArg], {
stdio: "inherit",
env: process.env,
});
if (result.status === 0) return;
if (typeof result.status === "number" && result.status !== 0) {
process.exit(result.status);
}
throw result.error ?? new Error("Failed to verify native runtime dependencies.");
};
async function main() {
const dev = process.argv.includes("--dev");
verifyNativeRuntime(dev);
const hostnames = Array.from(new Set(resolveHosts(process.env)));
const hostname = hostnames[0] ?? "127.0.0.1";
const port = resolvePort();
@@ -46,27 +56,7 @@ async function main() {
token: process.env.STUDIO_ACCESS_TOKEN,
});
const proxy = createGatewayProxy({
loadUpstreamSettings: async () => {
const settings = loadUpstreamGatewaySettings(process.env);
return { url: settings.url, token: settings.token };
},
allowWs: (req) => {
if (resolvePathname(req.url) !== "/api/gateway/ws") return false;
if (!accessGate.allowUpgrade(req)) return false;
return true;
},
});
await app.prepare();
const handleUpgrade = app.getUpgradeHandler();
const handleServerUpgrade = (req, socket, head) => {
if (resolvePathname(req.url) === "/api/gateway/ws") {
proxy.handleUpgrade(req, socket, head);
return;
}
handleUpgrade(req, socket, head);
};
const createServer = () =>
http.createServer((req, res) => {
@@ -76,21 +66,6 @@ async function main() {
const servers = hostnames.map(() => createServer());
const attachUpgradeHandlers = (server) => {
server.on("upgrade", handleServerUpgrade);
server.on("newListener", (eventName, listener) => {
if (eventName !== "upgrade") return;
if (listener === handleServerUpgrade) return;
process.nextTick(() => {
server.removeListener("upgrade", listener);
});
});
};
for (const server of servers) {
attachUpgradeHandlers(server);
}
const listenOnHost = (server, host) =>
new Promise((resolve, reject) => {
const onError = (err) => {
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { executeGatewayIntent, parseIntentBody } from "@/lib/controlplane/intent-route";
export const runtime = "nodejs";
export async function POST(request: Request) {
const bodyOrError = await parseIntentBody(request);
if (bodyOrError instanceof Response) {
return bodyOrError as NextResponse;
}
const agentId = typeof bodyOrError.agentId === "string" ? bodyOrError.agentId.trim() : "";
const name = typeof bodyOrError.name === "string" ? bodyOrError.name.trim() : "";
const content = typeof bodyOrError.content === "string" ? bodyOrError.content : null;
if (!agentId || !name || content === null) {
return NextResponse.json({ error: "agentId, name, and content are required." }, { status: 400 });
}
return await executeGatewayIntent("agents.files.set", {
agentId,
name,
content,
});
}
@@ -0,0 +1,260 @@
import { NextResponse } from "next/server";
import {
ensureDomainIntentRuntime,
parseIntentBody,
} from "@/lib/controlplane/intent-route";
import { ControlPlaneGatewayError } from "@/lib/controlplane/openclaw-adapter";
import type { ControlPlaneRuntime } from "@/lib/controlplane/runtime";
type AgentSkillsAccessMode = "all" | "none" | "allowlist";
type ConfigAgentEntry = Record<string, unknown> & { id: string };
type GatewayConfigSnapshot = {
config?: unknown;
hash?: string;
exists?: boolean;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object" && !Array.isArray(value));
const readConfigAgentList = (
config: Record<string, unknown> | undefined
): ConfigAgentEntry[] => {
if (!config) return [];
const agents = isRecord(config.agents) ? config.agents : null;
const list = Array.isArray(agents?.list) ? agents.list : [];
return list.filter((entry): entry is ConfigAgentEntry => {
if (!isRecord(entry)) return false;
if (typeof entry.id !== "string") return false;
return entry.id.trim().length > 0;
});
};
const writeConfigAgentList = (
config: Record<string, unknown>,
list: ConfigAgentEntry[]
): Record<string, unknown> => {
const agents = isRecord(config.agents) ? { ...config.agents } : {};
return { ...config, agents: { ...agents, list } };
};
const upsertConfigAgentEntry = (
list: ConfigAgentEntry[],
agentId: string,
updater: (entry: ConfigAgentEntry) => ConfigAgentEntry
): { list: ConfigAgentEntry[]; entry: ConfigAgentEntry } => {
let updatedEntry: ConfigAgentEntry | null = null;
const nextList = list.map((entry) => {
if (entry.id !== agentId) return entry;
const next = updater({ ...entry, id: agentId });
updatedEntry = next;
return next;
});
if (!updatedEntry) {
updatedEntry = updater({ id: agentId });
nextList.push(updatedEntry);
}
return { list: nextList, entry: updatedEntry };
};
const normalizeSkillAllowlistInput = (values: unknown): string[] => {
if (!Array.isArray(values)) return [];
const next = values
.filter((value): value is string => typeof value === "string")
.map((value) => value.trim())
.filter((value) => value.length > 0);
return Array.from(new Set(next)).sort((a, b) => a.localeCompare(b));
};
const areStringArraysEqual = (a: readonly string[], b: readonly string[]): boolean => {
if (a.length !== b.length) return false;
for (let index = 0; index < a.length; index += 1) {
if (a[index] !== b[index]) return false;
}
return true;
};
const buildAgentSkillsConfig = (params: {
baseConfig: Record<string, unknown>;
agentId: string;
mode: AgentSkillsAccessMode;
skillNames?: string[];
}): Record<string, unknown> => {
const list = readConfigAgentList(params.baseConfig);
const currentEntry = list.find((entry) => entry.id === params.agentId);
const hasEntry = Boolean(currentEntry);
const currentRawSkills = currentEntry?.skills;
if (params.mode === "all") {
if (!hasEntry) {
return params.baseConfig;
}
if (!Object.prototype.hasOwnProperty.call(currentEntry, "skills")) {
return params.baseConfig;
}
}
if (params.mode === "none" && Array.isArray(currentRawSkills) && currentRawSkills.length === 0) {
return params.baseConfig;
}
if (params.mode === "allowlist") {
const rawSkills = params.skillNames;
if (!rawSkills) {
throw new Error("Skills allowlist is required when mode is allowlist.");
}
const normalizedNext = normalizeSkillAllowlistInput(rawSkills);
if (Array.isArray(currentRawSkills)) {
const normalizedCurrent = normalizeSkillAllowlistInput(currentRawSkills);
if (areStringArraysEqual(normalizedCurrent, normalizedNext)) {
return params.baseConfig;
}
}
}
const { list: nextList } = upsertConfigAgentEntry(list, params.agentId, (entry) => {
const next: ConfigAgentEntry = { ...entry, id: params.agentId };
if (params.mode === "all") {
if ("skills" in next) {
delete next.skills;
}
return next;
}
if (params.mode === "none") {
next.skills = [];
return next;
}
const rawSkills = params.skillNames;
if (!rawSkills) {
throw new Error("Skills allowlist is required when mode is allowlist.");
}
next.skills = normalizeSkillAllowlistInput(rawSkills);
return next;
});
return writeConfigAgentList(params.baseConfig, nextList);
};
const isConfigConflict = (err: unknown): boolean => {
if (!(err instanceof ControlPlaneGatewayError)) return false;
if (err.code.trim().toUpperCase() !== "INVALID_REQUEST") return false;
const message = err.message.toLowerCase();
return (
message.includes("basehash") ||
message.includes("base hash") ||
message.includes("changed since last load") ||
message.includes("re-run config.get")
);
};
const mapIntentError = (error: unknown): NextResponse => {
if (error instanceof ControlPlaneGatewayError) {
if (error.code.trim().toUpperCase() === "GATEWAY_UNAVAILABLE") {
return NextResponse.json(
{
error: error.message,
code: "GATEWAY_UNAVAILABLE",
reason: "gateway_unavailable",
},
{ status: 503 }
);
}
return NextResponse.json(
{
error: error.message,
code: error.code,
details: error.details,
},
{ status: 400 }
);
}
const message = error instanceof Error ? error.message : "intent_failed";
return NextResponse.json({ error: message }, { status: 500 });
};
const applySkillsMode = async (params: {
runtime: ControlPlaneRuntime;
agentId: string;
mode: AgentSkillsAccessMode;
skillNames?: string[];
attempt?: number;
}): Promise<void> => {
const attempt = params.attempt ?? 0;
const snapshot = await params.runtime.callGateway<GatewayConfigSnapshot>("config.get", {});
const baseConfig = isRecord(snapshot.config) ? snapshot.config : {};
const nextConfig = buildAgentSkillsConfig({
baseConfig,
agentId: params.agentId,
mode: params.mode,
skillNames: params.skillNames,
});
if (nextConfig === baseConfig) {
return;
}
const payload: Record<string, unknown> = {
raw: JSON.stringify(nextConfig, null, 2),
};
const requiresBaseHash = snapshot.exists !== false;
const baseHash = requiresBaseHash ? snapshot.hash?.trim() : undefined;
if (requiresBaseHash && !baseHash) {
throw new Error("Gateway config hash unavailable; re-run config.get.");
}
if (baseHash) {
payload.baseHash = baseHash;
}
try {
await params.runtime.callGateway("config.set", payload);
} catch (error) {
if (attempt >= 1 || !isConfigConflict(error)) {
throw error;
}
await applySkillsMode({ ...params, attempt: attempt + 1 });
}
};
export const runtime = "nodejs";
export async function POST(request: Request) {
const bodyOrError = await parseIntentBody(request);
if (bodyOrError instanceof Response) {
return bodyOrError as NextResponse;
}
const agentId = typeof bodyOrError.agentId === "string" ? bodyOrError.agentId.trim() : "";
const modeRaw = typeof bodyOrError.mode === "string" ? bodyOrError.mode.trim() : "";
if (!agentId) {
return NextResponse.json({ error: "agentId is required." }, { status: 400 });
}
if (modeRaw !== "all" && modeRaw !== "none" && modeRaw !== "allowlist") {
return NextResponse.json({ error: "mode must be one of: all, none, allowlist." }, { status: 400 });
}
const mode = modeRaw as AgentSkillsAccessMode;
const skillNames = normalizeSkillAllowlistInput(bodyOrError.skillNames);
if (mode === "allowlist" && skillNames.length === 0) {
return NextResponse.json(
{ error: "skillNames must contain at least one value when mode is allowlist." },
{ status: 400 }
);
}
const runtimeOrError = await ensureDomainIntentRuntime();
if (runtimeOrError instanceof Response) {
return runtimeOrError as NextResponse;
}
try {
await applySkillsMode({
runtime: runtimeOrError,
agentId,
mode,
...(mode === "allowlist" ? { skillNames } : {}),
});
return NextResponse.json({ ok: true, payload: { updated: true } });
} catch (error) {
return mapIntentError(error);
}
}
+2 -1
View File
@@ -13,5 +13,6 @@ export async function POST(request: Request) {
if (!sessionKey) {
return NextResponse.json({ error: "sessionKey is required." }, { status: 400 });
}
return await executeGatewayIntent("chat.abort", { sessionKey });
const runId = typeof bodyOrError.runId === "string" ? bodyOrError.runId.trim() : "";
return await executeGatewayIntent("chat.abort", runId ? { sessionKey, runId } : { sessionKey });
}
+20
View File
@@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { executeGatewayIntent, parseIntentBody } from "@/lib/controlplane/intent-route";
export const runtime = "nodejs";
export async function POST(request: Request) {
const bodyOrError = await parseIntentBody(request);
if (bodyOrError instanceof Response) {
return bodyOrError as NextResponse;
}
const name = typeof bodyOrError.name === "string" ? bodyOrError.name.trim() : "";
const agentId = typeof bodyOrError.agentId === "string" ? bodyOrError.agentId.trim() : "";
if (!name || !agentId) {
return NextResponse.json({ error: "name and agentId are required." }, { status: 400 });
}
return await executeGatewayIntent("cron.add", bodyOrError);
}
+19
View File
@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { executeGatewayIntent, parseIntentBody } from "@/lib/controlplane/intent-route";
export const runtime = "nodejs";
export async function POST(request: Request) {
const bodyOrError = await parseIntentBody(request);
if (bodyOrError instanceof Response) {
return bodyOrError as NextResponse;
}
const id = typeof bodyOrError.id === "string" ? bodyOrError.id.trim() : "";
if (!id) {
return NextResponse.json({ error: "id is required." }, { status: 400 });
}
return await executeGatewayIntent("cron.remove", { id });
}
+22
View File
@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { executeGatewayIntent, parseIntentBody } from "@/lib/controlplane/intent-route";
export const runtime = "nodejs";
export async function POST(request: Request) {
const bodyOrError = await parseIntentBody(request);
if (bodyOrError instanceof Response) {
return bodyOrError as NextResponse;
}
const id = typeof bodyOrError.id === "string" ? bodyOrError.id.trim() : "";
if (!id) {
return NextResponse.json({ error: "id is required." }, { status: 400 });
}
return await executeGatewayIntent("cron.run", {
id,
mode: "force",
});
}
@@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { executeGatewayIntent, parseIntentBody } from "@/lib/controlplane/intent-route";
export const runtime = "nodejs";
export async function POST(request: Request) {
const bodyOrError = await parseIntentBody(request);
if (bodyOrError instanceof Response) {
return bodyOrError as NextResponse;
}
const name = typeof bodyOrError.name === "string" ? bodyOrError.name.trim() : "";
const installId =
typeof bodyOrError.installId === "string" ? bodyOrError.installId.trim() : "";
if (!name || !installId) {
return NextResponse.json({ error: "name and installId are required." }, { status: 400 });
}
const timeoutMs =
typeof bodyOrError.timeoutMs === "number" && Number.isFinite(bodyOrError.timeoutMs)
? Math.max(1, Math.floor(bodyOrError.timeoutMs))
: undefined;
return await executeGatewayIntent("skills.install", {
name,
installId,
...(typeof timeoutMs === "number" ? { timeoutMs } : {}),
});
}
@@ -0,0 +1,32 @@
import { NextResponse } from "next/server";
import { executeGatewayIntent, parseIntentBody } from "@/lib/controlplane/intent-route";
export const runtime = "nodejs";
const hasOwn = (value: Record<string, unknown>, key: string) =>
Object.prototype.hasOwnProperty.call(value, key);
export async function POST(request: Request) {
const bodyOrError = await parseIntentBody(request);
if (bodyOrError instanceof Response) {
return bodyOrError as NextResponse;
}
const skillKey = typeof bodyOrError.skillKey === "string" ? bodyOrError.skillKey.trim() : "";
if (!skillKey) {
return NextResponse.json({ error: "skillKey is required." }, { status: 400 });
}
const includeEnabled = hasOwn(bodyOrError, "enabled");
const includeApiKey = hasOwn(bodyOrError, "apiKey");
if (!includeEnabled && !includeApiKey) {
return NextResponse.json({ error: "enabled or apiKey is required." }, { status: 400 });
}
return await executeGatewayIntent("skills.update", {
skillKey,
...(includeEnabled ? { enabled: bodyOrError.enabled } : {}),
...(includeApiKey ? { apiKey: bodyOrError.apiKey } : {}),
});
}
+19
View File
@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { executeRuntimeGatewayRead } from "@/lib/controlplane/runtime-read-route";
export const runtime = "nodejs";
export async function GET(request: Request) {
const url = new URL(request.url);
const agentId = (url.searchParams.get("agentId") ?? "").trim();
const name = (url.searchParams.get("name") ?? "").trim();
if (!agentId || !name) {
return NextResponse.json({ error: "agentId and name are required." }, { status: 400 });
}
return await executeRuntimeGatewayRead("agents.files.get", {
agentId,
name,
});
}
+20
View File
@@ -0,0 +1,20 @@
import { NextResponse } from "next/server";
import { executeRuntimeGatewayRead } from "@/lib/controlplane/runtime-read-route";
export const runtime = "nodejs";
export async function GET(request: Request) {
const url = new URL(request.url);
const sessionKey = (url.searchParams.get("sessionKey") ?? "").trim();
if (!sessionKey) {
return NextResponse.json({ error: "sessionKey is required." }, { status: 400 });
}
const limitRaw = (url.searchParams.get("limit") ?? "0").trim();
const limit = Number(limitRaw);
return await executeRuntimeGatewayRead("chat.history", {
sessionKey,
...(Number.isFinite(limit) && limit > 0 ? { limit: Math.floor(limit) } : {}),
});
}
+7
View File
@@ -0,0 +1,7 @@
import { executeRuntimeGatewayRead } from "@/lib/controlplane/runtime-read-route";
export const runtime = "nodejs";
export async function GET() {
return await executeRuntimeGatewayRead("config.get", {});
}
+11
View File
@@ -0,0 +1,11 @@
import { executeRuntimeGatewayRead } from "@/lib/controlplane/runtime-read-route";
export const runtime = "nodejs";
export async function GET(request: Request) {
const url = new URL(request.url);
const includeDisabled = (url.searchParams.get("includeDisabled") ?? "true").trim();
return await executeRuntimeGatewayRead("cron.list", {
includeDisabled: includeDisabled !== "false",
});
}
+7
View File
@@ -0,0 +1,7 @@
import { executeRuntimeGatewayRead } from "@/lib/controlplane/runtime-read-route";
export const runtime = "nodejs";
export async function GET() {
return await executeRuntimeGatewayRead("models.list", {});
}
+21
View File
@@ -0,0 +1,21 @@
import { executeRuntimeGatewayRead } from "@/lib/controlplane/runtime-read-route";
export const runtime = "nodejs";
export async function GET(request: Request) {
const url = new URL(request.url);
const agentId = (url.searchParams.get("agentId") ?? "").trim();
const includeGlobal = (url.searchParams.get("includeGlobal") ?? "false").trim() !== "false";
const includeUnknown = (url.searchParams.get("includeUnknown") ?? "false").trim() !== "false";
const search = (url.searchParams.get("search") ?? "").trim();
const limitRaw = (url.searchParams.get("limit") ?? "0").trim();
const limit = Number(limitRaw);
return await executeRuntimeGatewayRead("sessions.list", {
...(agentId ? { agentId } : {}),
includeGlobal,
includeUnknown,
...(search ? { search } : {}),
...(Number.isFinite(limit) && limit > 0 ? { limit: Math.floor(limit) } : {}),
});
}
@@ -0,0 +1,14 @@
import { NextResponse } from "next/server";
import { executeRuntimeGatewayRead } from "@/lib/controlplane/runtime-read-route";
export const runtime = "nodejs";
export async function GET(request: Request) {
const url = new URL(request.url);
const agentId = (url.searchParams.get("agentId") ?? "").trim();
if (!agentId) {
return NextResponse.json({ error: "agentId is required." }, { status: 400 });
}
return await executeRuntimeGatewayRead("skills.status", { agentId });
}
+87 -4
View File
@@ -1,7 +1,10 @@
import { NextResponse } from "next/server";
import { type StudioSettingsPatch } from "@/lib/studio/settings";
import { isStudioDomainApiModeEnabled } from "@/lib/controlplane/runtime";
import {
isStudioDomainApiModeEnabled,
peekControlPlaneRuntime,
} from "@/lib/controlplane/runtime";
import {
applyStudioSettingsPatch,
loadLocalGatewayDefaults,
@@ -15,13 +18,88 @@ export const runtime = "nodejs";
const isPatch = (value: unknown): value is StudioSettingsPatch =>
Boolean(value && typeof value === "object");
const buildSettingsResponseBody = () => {
type RuntimeReconnectMetadata = {
attempted: boolean;
restarted: boolean;
reason?: string;
previousStatus?: string;
error?: string;
};
const normalizeGatewaySettings = (settings: ReturnType<typeof loadStudioSettings>) => {
const gateway = settings.gateway ?? null;
return {
url: typeof gateway?.url === "string" ? gateway.url.trim() : "",
token: typeof gateway?.token === "string" ? gateway.token.trim() : "",
};
};
const gatewaySettingsChanged = (
previous: ReturnType<typeof loadStudioSettings>,
next: ReturnType<typeof loadStudioSettings>
) => {
const left = normalizeGatewaySettings(previous);
const right = normalizeGatewaySettings(next);
return left.url !== right.url || left.token !== right.token;
};
const reconnectRuntimeForGatewaySettingsChange = async (
previous: ReturnType<typeof loadStudioSettings>,
next: ReturnType<typeof loadStudioSettings>
): Promise<RuntimeReconnectMetadata | null> => {
if (!gatewaySettingsChanged(previous, next)) return null;
if (!isStudioDomainApiModeEnabled()) {
return {
attempted: false,
restarted: false,
reason: "domain_api_mode_disabled",
};
}
const runtime = peekControlPlaneRuntime();
if (!runtime) {
return {
attempted: false,
restarted: false,
reason: "runtime_not_initialized",
};
}
const previousStatus = runtime.connectionStatus();
if (previousStatus === "stopped") {
return {
attempted: false,
restarted: false,
reason: "runtime_stopped",
previousStatus,
};
}
try {
await runtime.reconnectForGatewaySettingsChange();
return {
attempted: true,
restarted: true,
previousStatus,
};
} catch (error) {
const message =
error instanceof Error ? error.message : "controlplane_reconnect_failed";
console.error("Failed to reconnect control-plane runtime after gateway settings update.", error);
return {
attempted: true,
restarted: false,
previousStatus,
error: message,
};
}
};
const buildSettingsResponseBody = (metadata?: RuntimeReconnectMetadata | null) => {
const settings = loadStudioSettings();
const localGatewayDefaults = loadLocalGatewayDefaults();
return {
settings: redactStudioSettingsSecrets(settings),
localGatewayDefaults: redactLocalGatewayDefaultsSecrets(localGatewayDefaults),
domainApiModeEnabled: isStudioDomainApiModeEnabled(),
...(metadata ? { runtimeReconnect: metadata } : {}),
};
};
@@ -41,8 +119,13 @@ export async function PUT(request: Request) {
if (!isPatch(body)) {
return NextResponse.json({ error: "Invalid settings payload." }, { status: 400 });
}
applyStudioSettingsPatch(body);
return NextResponse.json(buildSettingsResponseBody());
const previousSettings = loadStudioSettings();
const nextSettings = applyStudioSettingsPatch(body);
const runtimeReconnect = await reconnectRuntimeForGatewaySettingsChange(
previousSettings,
nextSettings
);
return NextResponse.json(buildSettingsResponseBody(runtimeReconnect));
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to save studio settings.";
console.error(message);
+100 -73
View File
@@ -16,10 +16,8 @@ import { EmptyStatePanel } from "@/features/agents/components/EmptyStatePanel";
import {
isHeartbeatPrompt,
} from "@/lib/text/message-extract";
import {
useGatewayConnection,
type GatewayStatus,
} from "@/lib/gateway/GatewayClient";
import { useStudioGatewaySettings } from "@/lib/studio/useStudioGatewaySettings";
import type { GatewayStatus } from "@/lib/gateway/gateway-status";
import type { ControlPlaneOutboxEntry } from "@/lib/controlplane/contracts";
import {
type GatewayModelChoice,
@@ -37,7 +35,6 @@ import { createGatewayRuntimeEventHandler } from "@/features/agents/state/gatewa
import {
type CronJobSummary,
formatCronJobDisplay,
listCronJobs,
resolveLatestCronJobForAgent,
} from "@/lib/cron/types";
import {
@@ -51,8 +48,8 @@ import { applySessionSettingMutation } from "@/features/agents/state/sessionSett
import type { AgentCreateModalSubmitPayload } from "@/features/agents/creation/types";
import {
isGatewayDisconnectLikeError,
type EventFrame,
} from "@/lib/gateway/GatewayClient";
} from "@/lib/gateway/gateway-disconnect";
import type { EventFrame } from "@/lib/gateway/gateway-frames";
import {
useConfigMutationQueue,
type ConfigMutationKind,
@@ -115,6 +112,11 @@ import {
type SettingsRouteTab,
} from "@/features/agents/operations/settingsRouteWorkflow";
import { useSettingsRouteController } from "@/features/agents/operations/useSettingsRouteController";
import {
loadDomainChatHistory,
listDomainCronJobs,
listDomainSessions,
} from "@/lib/controlplane/domain-runtime-client";
const PENDING_EXEC_APPROVAL_PRUNE_GRACE_MS = 500;
type MobilePane = "fleet" | "chat";
@@ -227,10 +229,12 @@ const AgentStudioPage = () => {
useLocalGatewayDefaults,
setGatewayUrl,
setToken,
} = useGatewayConnection(settingsCoordinator);
const useDomainApiMode = domainApiModeEnabled === true;
const coreConnected = useDomainApiMode ? true : status === "connected";
const coreStatus: GatewayStatus = coreConnected ? "connected" : status;
} = useStudioGatewaySettings(settingsCoordinator);
const useDomainApiMode = domainApiModeEnabled;
const gatewayStatus: GatewayStatus = status;
const gatewayConnected = gatewayStatus === "connected";
const coreConnected = useDomainApiMode ? true : gatewayConnected;
const coreStatus: GatewayStatus = useDomainApiMode ? "connected" : gatewayStatus;
const runtimeWriteTransport = useMemo(
() =>
createRuntimeWriteTransport({
@@ -452,9 +456,36 @@ const AgentStudioPage = () => {
}, []);
const specialLatestUpdate = useMemo(() => {
const callGateway = async (method: string, params: unknown): Promise<unknown> => {
if (method === "sessions.list") {
const body = (params ?? {}) as {
agentId?: string;
includeGlobal?: boolean;
includeUnknown?: boolean;
search?: string;
limit?: number;
};
return await listDomainSessions({
agentId: body.agentId ?? "",
includeGlobal: body.includeGlobal,
includeUnknown: body.includeUnknown,
search: body.search,
limit: body.limit,
});
}
if (method === "chat.history") {
const body = (params ?? {}) as { sessionKey?: string; limit?: number };
return await loadDomainChatHistory({
sessionKey: body.sessionKey ?? "",
limit: body.limit,
});
}
throw new Error(`Unsupported special latest-update method in domain mode: ${method}`);
};
return createSpecialLatestUpdateOperation({
callGateway: (method, params) => client.call(method, params),
listCronJobs: () => listCronJobs(client, { includeDisabled: true }),
callGateway,
listCronJobs: () => listDomainCronJobs({ includeDisabled: true }),
resolveCronJobForAgent,
formatCronJobDisplay,
dispatchUpdateAgent: (agentId, patch) => {
@@ -463,7 +494,7 @@ const AgentStudioPage = () => {
isDisconnectLikeError: isGatewayDisconnectLikeError,
logError: (message) => console.error(message),
});
}, [client, dispatch, resolveCronJobForAgent]);
}, [dispatch, resolveCronJobForAgent]);
const refreshHeartbeatLatestUpdate = useCallback(() => {
const agents = stateRef.current.agents;
@@ -520,7 +551,8 @@ const AgentStudioPage = () => {
const { refreshGatewayConfigSnapshot } = useGatewayConfigSyncController({
client,
status,
status: gatewayStatus,
useDomainApiReads: useDomainApiMode,
settingsRouteActive,
inspectSidebarAgentId,
gatewayConfigSnapshot,
@@ -535,7 +567,7 @@ const AgentStudioPage = () => {
const settingsMutationController = useAgentSettingsMutationController({
client,
runtimeWriteTransport,
status,
status: gatewayStatus,
isLocalGateway,
agents,
hasCreateBlock: Boolean(createAgentBlock),
@@ -583,7 +615,7 @@ const AgentStudioPage = () => {
queuedBlockedByRunningAgents,
activeConfigMutation,
} = useConfigMutationQueue({
status: coreStatus,
status: gatewayStatus,
hasRunningAgents,
hasRestartBlockInProgress,
});
@@ -661,7 +693,7 @@ const AgentStudioPage = () => {
useEffect(() => {
const commands = runStudioFocusedSelectionPersistenceOperation({
gatewayUrl,
status,
status: coreStatus,
focusedPreferencesLoaded,
agentsLoadedOnce,
selectedAgentId: state.selectedAgentId,
@@ -675,7 +707,7 @@ const AgentStudioPage = () => {
focusedPreferencesLoaded,
gatewayUrl,
settingsCoordinator,
status,
coreStatus,
state.selectedAgentId,
]);
@@ -814,7 +846,7 @@ const AgentStudioPage = () => {
} = useChatInteractionController({
client,
runtimeWriteTransport,
status: coreStatus,
status: gatewayStatus,
agents,
dispatch,
setError,
@@ -854,7 +886,7 @@ const AgentStudioPage = () => {
} = useSettingsRouteController({
settingsRouteActive,
settingsRouteAgentId,
status: coreStatus,
status: gatewayStatus,
agentsLoadedOnce,
selectedAgentId: state.selectedAgentId,
focusedAgentId: focusedAgent?.agentId ?? null,
@@ -918,7 +950,7 @@ const AgentStudioPage = () => {
await runCreateAgentMutationLifecycle(
{
payload,
status: coreStatus,
status: gatewayStatus,
hasCreateBlock: Boolean(createAgentBlock),
hasRenameBlock: hasRenameMutationBlock,
hasDeleteBlock: hasDeleteMutationBlock,
@@ -1019,7 +1051,7 @@ const AgentStudioPage = () => {
refreshGatewayConfigSnapshot,
runtimeWriteTransport,
setError,
coreStatus,
gatewayStatus,
]
);
@@ -1158,7 +1190,7 @@ const AgentStudioPage = () => {
const pauseRunForExecApproval = useCallback(
async (approval: PendingExecApproval, preferredAgentId?: string | null) => {
await runPauseRunForExecApprovalOperation({
status: coreStatus,
status: gatewayStatus,
runtimeWriteTransport,
approval,
preferredAgentId: preferredAgentId ?? null,
@@ -1168,7 +1200,7 @@ const AgentStudioPage = () => {
logWarn: (message, error) => console.warn(message, error),
});
},
[coreStatus, runtimeWriteTransport]
[gatewayStatus, runtimeWriteTransport]
);
const handleGatewayEventIngress = useCallback(
@@ -1243,49 +1275,39 @@ const AgentStudioPage = () => {
pendingDomainOutboxEntriesRef.current = [];
ingestDomainOutboxEntries(pendingEntries);
}
let unsubscribeGatewayEvents: (() => void) | null = null;
let stream: EventSource | null = null;
if (useDomainApiMode) {
stream = new EventSource("/api/runtime/stream");
stream.addEventListener("gateway.event", (raw) => {
const message = raw as MessageEvent<string>;
try {
const parsed = JSON.parse(message.data) as {
event?: string;
payload?: unknown;
seq?: number;
};
if (typeof parsed.event !== "string") return;
const frame: EventFrame = {
type: "event",
event: parsed.event,
payload: parsed.payload,
...(typeof parsed.seq === "number" ? { seq: parsed.seq } : {}),
};
handler.handleEvent(frame);
handleGatewayEventIngress(frame);
} catch {}
});
stream.addEventListener("runtime.status", () => {
void loadSummarySnapshot();
});
stream.onerror = () => {
// EventSource performs automatic reconnect; keep warning low-noise.
};
} else {
unsubscribeGatewayEvents = client.onEvent((event: EventFrame) => {
handler.handleEvent(event);
handleGatewayEventIngress(event);
});
}
stream = new EventSource("/api/runtime/stream");
stream.addEventListener("gateway.event", (raw) => {
const message = raw as MessageEvent<string>;
try {
const parsed = JSON.parse(message.data) as {
event?: string;
payload?: unknown;
seq?: number;
};
if (typeof parsed.event !== "string") return;
const frame: EventFrame = {
type: "event",
event: parsed.event,
payload: parsed.payload,
...(typeof parsed.seq === "number" ? { seq: parsed.seq } : {}),
};
handler.handleEvent(frame);
handleGatewayEventIngress(frame);
} catch {}
});
stream.addEventListener("runtime.status", () => {
void loadSummarySnapshot();
});
stream.onerror = () => {
// EventSource performs automatic reconnect; keep warning low-noise.
};
return () => {
runtimeEventHandlerRef.current = null;
handler.dispose();
unsubscribeGatewayEvents?.();
stream?.close();
};
}, [
client,
dispatch,
loadAgentHistory,
loadSummarySnapshot,
@@ -1295,7 +1317,6 @@ const AgentStudioPage = () => {
specialLatestUpdate,
handleGatewayEventIngress,
ingestDomainOutboxEntries,
useDomainApiMode,
coreStatus,
]);
@@ -1319,7 +1340,7 @@ const AgentStudioPage = () => {
: queuedConfigMutationCount > 0
? queuedBlockedByRunningAgents
? `Queued ${queuedConfigMutationCount} config change${queuedConfigMutationCount === 1 ? "" : "s"}; waiting for ${runningAgentCount} running agent${runningAgentCount === 1 ? "" : "s"} to finish`
: !coreConnected
: !gatewayConnected
? `Queued ${queuedConfigMutationCount} config change${queuedConfigMutationCount === 1 ? "" : "s"}; waiting for gateway connection`
: `Queued ${queuedConfigMutationCount} config change${queuedConfigMutationCount === 1 ? "" : "s"}`
: null;
@@ -1337,7 +1358,7 @@ const AgentStudioPage = () => {
sawDisconnect: restartingMutationBlock.sawDisconnect,
}
: null,
status,
status: gatewayStatus,
});
const restartingMutationModalTestId = restartingMutationBlock
? restartingMutationBlock.kind === "delete-agent"
@@ -1389,7 +1410,7 @@ const AgentStudioPage = () => {
<div className="relative min-h-screen w-screen overflow-hidden bg-background">
<div className="relative z-10 flex h-screen flex-col">
<HeaderBar
status={status}
status={gatewayStatus}
onConnectionSettings={() => setShowConnectionPanel(true)}
/>
<div className="flex min-h-0 flex-1 flex-col gap-4 px-3 pb-3 pt-3 sm:px-4 sm:pb-4 sm:pt-4 md:px-6 md:pb-6 md:pt-4">
@@ -1408,7 +1429,7 @@ const AgentStudioPage = () => {
gatewayUrl={gatewayUrl}
token={token}
localGatewayDefaults={localGatewayDefaults}
status={status}
status={gatewayStatus}
error={gatewayError}
onGatewayUrlChange={setGatewayUrl}
onTokenChange={setToken}
@@ -1447,7 +1468,7 @@ const AgentStudioPage = () => {
) : null}
<div className="relative z-10 flex h-screen flex-col">
<HeaderBar
status={status}
status={gatewayStatus}
onConnectionSettings={() => setShowConnectionPanel(true)}
/>
<div className="flex min-h-0 flex-1 flex-col gap-3 px-3 pb-3 pt-2 sm:px-4 sm:pb-4 sm:pt-3 md:px-5 md:pb-5 md:pt-3">
@@ -1457,7 +1478,7 @@ const AgentStudioPage = () => {
<ConnectionPanel
gatewayUrl={gatewayUrl}
token={token}
status={status}
status={gatewayStatus}
error={gatewayError}
onGatewayUrlChange={setGatewayUrl}
onTokenChange={setToken}
@@ -1557,7 +1578,7 @@ const AgentStudioPage = () => {
{inspectSidebarAgent ? (
effectiveSettingsTab === "personality" ? (
<AgentBrainPanel
client={client}
gatewayStatus={gatewayStatus}
agents={agents}
selectedAgentId={inspectSidebarAgent.agentId}
onUnsavedChangesChange={setPersonalityHasUnsavedChanges}
@@ -1707,7 +1728,7 @@ const AgentStudioPage = () => {
onCreateAgent={() => {
handleOpenCreateAgentModal();
}}
createDisabled={!coreConnected || createAgentBusy || state.loading}
createDisabled={!gatewayConnected || createAgentBusy || state.loading}
createBusy={createAgentBusy}
onSelectAgent={handleFleetSelectAgent}
/>
@@ -1722,7 +1743,7 @@ const AgentStudioPage = () => {
<AgentChatPanel
agent={focusedAgent}
isSelected={false}
canSend={coreConnected}
canSend={gatewayConnected}
models={gatewayModels}
stopBusy={stopBusyAgentId === focusedAgent.agentId}
stopDisabledReason={focusedAgentStopDisabledReason}
@@ -1751,7 +1772,13 @@ const AgentStudioPage = () => {
onRemoveQueuedMessage={(index) =>
removeQueuedMessage(focusedAgent.agentId, index)
}
onStopRun={() => handleStopRun(focusedAgent.agentId, focusedAgent.sessionKey)}
onStopRun={() =>
handleStopRun(
focusedAgent.agentId,
focusedAgent.sessionKey,
focusedAgent.runId
)
}
onAvatarShuffle={() => handleAvatarShuffle(focusedAgent.agentId)}
pendingExecApprovals={focusedPendingExecApprovals}
onResolveExecApproval={(id, decision) => {
@@ -1766,7 +1793,7 @@ const AgentStudioPage = () => {
description={
hasAnyAgents
? undefined
: coreConnected
: gatewayConnected
? "Use New Agent in the sidebar to add your first agent."
: "Connect to your gateway to load agents into the studio."
}
@@ -15,7 +15,7 @@ import {
type CronTranscriptIntent,
} from "@/features/agents/state/gatewayEventIngressWorkflow";
import type { AgentState } from "@/features/agents/state/store";
import type { EventFrame } from "@/lib/gateway/GatewayClient";
import type { EventFrame } from "@/lib/gateway/gateway-frames";
export type ExecApprovalPendingSnapshot = ApprovalPendingState;
@@ -1,5 +1,5 @@
import type { AgentState } from "@/features/agents/state/store";
import type { EventFrame } from "@/lib/gateway/GatewayClient";
import type { EventFrame } from "@/lib/gateway/gateway-frames";
import type { ExecApprovalDecision } from "@/features/agents/approvals/types";
type RequestedPayload = {
@@ -5,7 +5,7 @@ import {
resolveExecApprovalAgentId,
} from "@/features/agents/approvals/execApprovalEvents";
import type { AgentState } from "@/features/agents/state/store";
import type { EventFrame } from "@/lib/gateway/GatewayClient";
import type { EventFrame } from "@/lib/gateway/gateway-frames";
import { GatewayResponseError } from "@/lib/gateway/errors";
export type ExecApprovalEventEffects = {
@@ -15,7 +15,7 @@ import {
import { sendChatMessageViaStudio } from "@/features/agents/operations/chatSendOperation";
import type { RuntimeWriteTransport } from "@/features/agents/operations/runtimeWriteTransport";
import type { AgentState } from "@/features/agents/state/store";
import type { EventFrame } from "@/lib/gateway/GatewayClient";
import type { EventFrame } from "@/lib/gateway/gateway-frames";
import { EXEC_APPROVAL_AUTO_RESUME_MARKER } from "@/lib/text/message-extract";
type GatewayClientLike = {
@@ -17,9 +17,9 @@ import {
import type { AgentState } from "@/features/agents/state/store";
import type { CronCreateDraft, CronCreateTemplateId } from "@/lib/cron/createPayloadBuilder";
import { formatCronPayload, formatCronSchedule, type CronJobSummary } from "@/lib/cron/types";
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
import type { GatewayStatus } from "@/lib/gateway/gateway-status";
import type { SkillStatusReport } from "@/lib/skills/types";
import { readGatewayAgentFile, writeGatewayAgentFile } from "@/lib/gateway/agentFiles";
import { readDomainAgentFile, writeDomainAgentFile } from "@/lib/controlplane/domain-runtime-client";
import {
resolveExecutionRoleFromAgent,
resolvePresetDefaultsForRole,
@@ -1201,10 +1201,10 @@ type UseAgentFilesEditorResult = {
};
const useAgentFilesEditor = (params: {
client: GatewayClient | null | undefined;
agentId: string | null | undefined;
gatewayStatus: GatewayStatus;
}): UseAgentFilesEditorResult => {
const { client, agentId } = params;
const { agentId, gatewayStatus } = params;
const [agentFiles, setAgentFiles] = useState(createAgentFilesState);
const [agentFilesLoading, setAgentFilesLoading] = useState(false);
const [agentFilesSaving, setAgentFilesSaving] = useState(false);
@@ -1233,13 +1233,17 @@ const useAgentFilesEditor = (params: {
setAgentFilesError("Agent ID is missing for this agent.");
return;
}
if (!client) {
setAgentFilesError("Gateway client is not available.");
if (gatewayStatus !== "connected") {
if (gatewayStatus === "connecting") {
setAgentFilesError(null);
} else {
setAgentFilesError("Gateway is not connected.");
}
return;
}
const results = await Promise.all(
AGENT_FILE_NAMES.map(async (name) => {
const file = await readGatewayAgentFile({ client, agentId: trimmedAgentId, name });
const file = await readDomainAgentFile({ agentId: trimmedAgentId, name });
return { name, content: file.content, exists: file.exists };
})
);
@@ -1260,7 +1264,7 @@ const useAgentFilesEditor = (params: {
} finally {
setAgentFilesLoading(false);
}
}, [agentId, client]);
}, [agentId, gatewayStatus]);
const saveAgentFiles = useCallback(async () => {
setAgentFilesSaving(true);
@@ -1271,14 +1275,13 @@ const useAgentFilesEditor = (params: {
setAgentFilesError("Agent ID is missing for this agent.");
return false;
}
if (!client) {
setAgentFilesError("Gateway client is not available.");
if (gatewayStatus !== "connected") {
setAgentFilesError("Gateway is not connected.");
return false;
}
await Promise.all(
AGENT_FILE_NAMES.map(async (name) => {
await writeGatewayAgentFile({
client,
await writeDomainAgentFile({
agentId: trimmedAgentId,
name,
content: agentFiles[name].content,
@@ -1303,7 +1306,7 @@ const useAgentFilesEditor = (params: {
} finally {
setAgentFilesSaving(false);
}
}, [agentFiles, agentId, client]);
}, [agentFiles, agentId, gatewayStatus]);
const setAgentFileContent = useCallback((name: AgentFileName, value: string) => {
if (!isAgentFileName(name)) return;
@@ -1337,7 +1340,7 @@ const useAgentFilesEditor = (params: {
};
type AgentBrainPanelProps = {
client: GatewayClient;
gatewayStatus: GatewayStatus;
agents: AgentState[];
selectedAgentId: string | null;
onUnsavedChangesChange?: (dirty: boolean) => void;
@@ -1357,7 +1360,7 @@ const AgentBrainPanelSection = ({
);
export const AgentBrainPanel = ({
client,
gatewayStatus,
agents,
selectedAgentId,
onUnsavedChangesChange,
@@ -1379,7 +1382,10 @@ export const AgentBrainPanel = ({
setAgentFileContent,
saveAgentFiles,
discardAgentFileChanges,
} = useAgentFilesEditor({ client, agentId: selectedAgent?.agentId ?? null });
} = useAgentFilesEditor({
agentId: selectedAgent?.agentId ?? null,
gatewayStatus,
});
const draft = useMemo(() => parsePersonalityFiles(agentFiles), [agentFiles]);
const setIdentityField = useCallback(
@@ -1,4 +1,4 @@
import type { GatewayStatus } from "@/lib/gateway/GatewayClient";
import type { GatewayStatus } from "@/lib/gateway/gateway-status";
import { X } from "lucide-react";
import { resolveGatewayStatusBadgeClass, resolveGatewayStatusLabel } from "./colorSemantics";
@@ -1,6 +1,6 @@
import { useMemo, useState } from "react";
import { Check, Copy, Eye, EyeOff, Loader2 } from "lucide-react";
import type { GatewayStatus } from "@/lib/gateway/GatewayClient";
import type { GatewayStatus } from "@/lib/gateway/gateway-status";
import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway";
import type { StudioGatewaySettings } from "@/lib/studio/settings";
+1 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { ThemeToggle } from "@/components/theme-toggle";
import type { GatewayStatus } from "@/lib/gateway/GatewayClient";
import type { GatewayStatus } from "@/lib/gateway/gateway-status";
import { Plug } from "lucide-react";
import { resolveGatewayStatusBadgeClass } from "./colorSemantics";
@@ -1,5 +1,5 @@
import type { AgentStatus } from "@/features/agents/state/store";
import type { GatewayStatus } from "@/lib/gateway/GatewayClient";
import type { GatewayStatus } from "@/lib/gateway/gateway-status";
export const AGENT_STATUS_LABEL: Record<AgentStatus, string> = {
idle: "Idle",
@@ -13,6 +13,20 @@ type GatewayClientLike = {
call: (method: string, params: unknown) => Promise<unknown>;
};
const callGateway = async <T>(
client: GatewayClientLike,
method: string,
params: unknown
): Promise<T> => {
const invoke = (
client as unknown as { call?: (nextMethod: string, nextParams: unknown) => Promise<unknown> }
).call;
if (typeof invoke !== "function") {
throw new Error("Gateway call transport is unavailable.");
}
return (await invoke(method, params)) as T;
};
type AgentsListResult = {
defaultId: string;
mainKey: string;
@@ -75,10 +89,11 @@ export async function hydrateAgentFleetFromGateway(params: {
let configSnapshot = params.cachedConfigSnapshot;
if (!configSnapshot) {
try {
configSnapshot = (await params.client.call(
configSnapshot = await callGateway<GatewayModelPolicySnapshot>(
params.client,
"config.get",
{}
)) as GatewayModelPolicySnapshot;
);
} catch (err) {
if (!params.isDisconnectLikeError(err)) {
logError("Failed to load gateway config while loading agents.", err);
@@ -98,17 +113,18 @@ export async function hydrateAgentFleetFromGateway(params: {
let execApprovalsSnapshot: ExecApprovalsSnapshot | null = null;
try {
execApprovalsSnapshot = (await params.client.call(
execApprovalsSnapshot = await callGateway<ExecApprovalsSnapshot>(
params.client,
"exec.approvals.get",
{}
)) as ExecApprovalsSnapshot;
);
} catch (err) {
if (!params.isDisconnectLikeError(err)) {
logError("Failed to load exec approvals while loading agents.", err);
}
}
const agentsResult = (await params.client.call("agents.list", {})) as AgentsListResult;
const agentsResult = await callGateway<AgentsListResult>(params.client, "agents.list", {});
const mainKey = agentsResult.mainKey?.trim() || "main";
const mainSessionKeyByAgent = new Map<string, SessionsListEntry | null>();
@@ -116,13 +132,13 @@ export async function hydrateAgentFleetFromGateway(params: {
agentsResult.agents.map(async (agent) => {
try {
const expectedMainKey = buildAgentMainSessionKey(agent.id, mainKey);
const sessions = (await params.client.call("sessions.list", {
const sessions = await callGateway<SessionsListResult>(params.client, "sessions.list", {
agentId: agent.id,
includeGlobal: false,
includeUnknown: false,
search: expectedMainKey,
limit: 4,
})) as SessionsListResult;
});
const entries = Array.isArray(sessions.sessions) ? sessions.sessions : [];
const mainEntry =
entries.find((entry) => isSameSessionKey(entry.key ?? "", expectedMainKey)) ?? null;
@@ -149,12 +165,12 @@ export async function hydrateAgentFleetFromGateway(params: {
).slice(0, 64);
if (sessionKeys.length > 0) {
const snapshot = await Promise.all([
params.client.call("status", {}) as Promise<SummaryStatusSnapshot>,
params.client.call("sessions.preview", {
callGateway<SummaryStatusSnapshot>(params.client, "status", {}),
callGateway<SummaryPreviewSnapshot>(params.client, "sessions.preview", {
keys: sessionKeys,
limit: 8,
maxChars: 240,
}) as Promise<SummaryPreviewSnapshot>,
}),
]);
statusSummary = snapshot[0] ?? null;
previewResult = snapshot[1] ?? null;
@@ -1,5 +1,5 @@
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
import { syncGatewaySessionSettings } from "@/lib/gateway/GatewayClient";
import { syncGatewaySessionSettings } from "@/lib/gateway/session-settings-sync";
import { readConfigAgentList, updateGatewayAgentOverrides } from "@/lib/gateway/agentConfig";
import {
createRuntimeWriteTransport,
@@ -7,7 +7,7 @@ import {
} from "@/features/agents/operations/runtimeWriteTransport";
type ExecutionRoleId = "conservative" | "collaborative" | "autonomous";
export type CommandModeId = "off" | "ask" | "auto";
type CommandModeId = "off" | "ask" | "auto";
export type AgentPermissionsDraft = {
commandMode: CommandModeId;
@@ -9,6 +9,20 @@ type GatewayClientLike = {
call: (method: string, params: unknown) => Promise<unknown>;
};
const callGateway = async <T>(
client: GatewayClientLike,
method: string,
params: unknown
): Promise<T> => {
const invoke = (
client as unknown as { call?: (nextMethod: string, nextParams: unknown) => Promise<unknown> }
).call;
if (typeof invoke !== "function") {
throw new Error("Gateway call transport is unavailable.");
}
return (await invoke(method, params)) as T;
};
type ReconcileCommand =
| { kind: "clearRunTracking"; runId: string }
| { kind: "dispatchUpdateAgent"; agentId: string; patch: Partial<AgentState> }
@@ -81,10 +95,10 @@ export const runAgentReconcileOperation = async (params: {
if (!params.claimRunId(runId)) continue;
try {
const result = (await params.client.call("agent.wait", {
const result = await callGateway<{ status?: unknown }>(params.client, "agent.wait", {
runId,
timeoutMs: 1,
})) as { status?: unknown };
});
const outcome = resolveReconcileWaitOutcome(result?.status);
if (!outcome) {
continue;
@@ -1,4 +1,4 @@
import type { GatewayStatus } from "@/lib/gateway/GatewayClient";
import type { GatewayStatus } from "@/lib/gateway/gateway-status";
type StopRunIntent =
| { kind: "deny"; reason: "not-connected" | "missing-session-key"; message: string }
@@ -34,10 +34,14 @@ type CronBusyState = {
type CronCreateDeps = {
buildInput?: (agentId: string, draft: CronCreateDraft) => CronJobCreateInput;
createCronJob?: (client: GatewayClient, input: CronJobCreateInput) => Promise<unknown>;
createCronJobForInput?: (input: CronJobCreateInput) => Promise<unknown>;
listCronJobs?: (
client: GatewayClient,
params: { includeDisabled?: boolean }
) => Promise<{ jobs: CronJobSummary[] }>;
listCronJobsWithoutClient?: (params: {
includeDisabled?: boolean;
}) => Promise<{ jobs: CronJobSummary[] }>;
};
const isCronActionBusy = (busy: CronBusyState) =>
@@ -70,14 +74,22 @@ export const performCronCreateFlow = async (params: {
const buildInput = params.deps?.buildInput ?? buildCronJobCreateInput;
const createCronJob = params.deps?.createCronJob ?? createCronJobDefault;
const listCronJobs = params.deps?.listCronJobs ?? listCronJobsDefault;
const createCronJobForInput = params.deps?.createCronJobForInput ?? null;
const listCronJobsWithoutClient = params.deps?.listCronJobsWithoutClient ?? null;
params.onBusyChange(true);
params.onError(null);
try {
const input = buildInput(resolvedAgentId, params.draft);
await createCronJob(params.client, input);
const listResult = await listCronJobs(params.client, { includeDisabled: true });
if (createCronJobForInput) {
await createCronJobForInput(input);
} else {
await createCronJob(params.client, input);
}
const listResult = listCronJobsWithoutClient
? await listCronJobsWithoutClient({ includeDisabled: true })
: await listCronJobs(params.client, { includeDisabled: true });
const jobs = sortCronJobsByUpdatedAt(filterCronJobsForAgent(listResult.jobs, resolvedAgentId));
params.onJobs(jobs);
return "created";
@@ -137,7 +137,7 @@ export const deleteAgentViaStudio = async (params: {
{
trashAgentState: async (agentId) => {
const { result } = await fetchJson<{ result: TrashAgentStateResult }>(
"/api/gateway/agent-state",
"/api/runtime/agent-state",
{
method: "POST",
headers: { "content-type": "application/json" },
@@ -148,7 +148,7 @@ export const deleteAgentViaStudio = async (params: {
},
restoreAgentState: async (agentId, trashDir) => {
const { result } = await fetchJson<{ result: RestoreAgentStateResult }>(
"/api/gateway/agent-state",
"/api/runtime/agent-state",
{
method: "PUT",
headers: { "content-type": "application/json" },
@@ -2,9 +2,9 @@ import type { GatewayStatus } from "@/features/agents/operations/gatewayRestartP
import type { AgentCreateModalSubmitPayload } from "@/features/agents/creation/types";
import type { ConfigMutationKind } from "@/features/agents/operations/useConfigMutationQueue";
export type MutationKind = "create-agent" | "rename-agent" | "delete-agent";
type MutationKind = "create-agent" | "rename-agent" | "delete-agent";
export type MutationBlockPhase = "queued" | "mutating" | "awaiting-restart";
type MutationBlockPhase = "queued" | "mutating" | "awaiting-restart";
export type MutationBlockState = {
kind: MutationKind;
@@ -189,7 +189,7 @@ export type CreateAgentBlockState = {
startedAt: number;
};
export type CreateAgentLifecycleCompletion = {
type CreateAgentLifecycleCompletion = {
agentId: string;
agentName: string;
};
@@ -1,6 +1,6 @@
import { postStudioIntent } from "@/lib/controlplane/intents-client";
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
import { syncGatewaySessionSettings } from "@/lib/gateway/GatewayClient";
import { syncGatewaySessionSettings } from "@/lib/gateway/session-settings-sync";
import { createGatewayAgent, deleteGatewayAgent, renameGatewayAgent } from "@/lib/gateway/agentConfig";
import {
readGatewayAgentExecApprovals,
@@ -26,7 +26,7 @@ export type RuntimeWriteTransport = {
execAsk?: "off" | "on-miss" | "always" | null;
}) => Promise<unknown>;
agentCreate: (params: { name: string }) => Promise<{ id: string; name: string }>;
chatAbort: (params: { sessionKey: string }) => Promise<void>;
chatAbort: (params: { sessionKey: string; runId?: string }) => Promise<void>;
sessionsReset: (params: { key: string }) => Promise<void>;
agentRename: (params: { agentId: string; name: string }) => Promise<void>;
agentDelete: (params: { agentId: string }) => Promise<void>;
@@ -53,6 +53,20 @@ const requireNonEmpty = (value: string, fieldLabel: string): string => {
return trimmed;
};
const callLegacyGateway = async <T>(
client: GatewayClient,
method: string,
params: unknown
): Promise<T> => {
const invoke = (
client as unknown as { call?: (nextMethod: string, nextParams: unknown) => Promise<unknown> }
).call;
if (typeof invoke !== "function") {
throw new Error("Legacy gateway client call transport is unavailable.");
}
return (await invoke(method, params)) as T;
};
const unwrapIntentPayload = <T>(result: unknown): T => {
if (isRecord(result) && "payload" in result) {
return result.payload as T;
@@ -98,7 +112,7 @@ export function createRuntimeWriteTransport(params: {
const result = await postIntent("/api/intents/chat-send", payload);
return unwrapIntentPayload<unknown>(result);
}
return await params.client.call("chat.send", payload);
return await callLegacyGateway(params.client, "chat.send", payload);
},
sessionSettingsSync: async ({
sessionKey,
@@ -170,13 +184,17 @@ export function createRuntimeWriteTransport(params: {
: normalizedName;
return { id: created.id, name: createdName };
},
chatAbort: async ({ sessionKey }) => {
chatAbort: async ({ sessionKey, runId }) => {
const normalizedSessionKey = requireNonEmpty(sessionKey, "Session key");
const normalizedRunId = typeof runId === "string" ? runId.trim() : "";
const payload = normalizedRunId
? { sessionKey: normalizedSessionKey, runId: normalizedRunId }
: { sessionKey: normalizedSessionKey };
if (params.useDomainIntents) {
await postIntent("/api/intents/chat-abort", { sessionKey: normalizedSessionKey });
await postIntent("/api/intents/chat-abort", payload);
return;
}
await params.client.call("chat.abort", { sessionKey: normalizedSessionKey });
await callLegacyGateway(params.client, "chat.abort", payload);
},
sessionsReset: async ({ key }) => {
const normalizedSessionKey = requireNonEmpty(key, "Session key");
@@ -184,7 +202,7 @@ export function createRuntimeWriteTransport(params: {
await postIntent("/api/intents/sessions-reset", { key: normalizedSessionKey });
return;
}
await params.client.call("sessions.reset", { key: normalizedSessionKey });
await callLegacyGateway(params.client, "sessions.reset", { key: normalizedSessionKey });
},
agentRename: async ({ agentId, name }) => {
const normalizedAgentId = requireNonEmpty(agentId, "Agent id");
@@ -216,7 +234,10 @@ export function createRuntimeWriteTransport(params: {
await postIntent("/api/intents/exec-approval-resolve", { id: normalizedId, decision });
return;
}
await params.client.call("exec.approval.resolve", { id: normalizedId, decision });
await callLegacyGateway(params.client, "exec.approval.resolve", {
id: normalizedId,
decision,
});
},
execApprovalsSet: async ({ agentId, role }) => {
const normalizedAgentId = requireNonEmpty(agentId, "Agent id");
@@ -264,7 +285,7 @@ export function createRuntimeWriteTransport(params: {
});
return;
}
await params.client.call("agent.wait", {
await callLegacyGateway(params.client, "agent.wait", {
runId: normalizedRunId,
...(typeof timeoutMs === "number" ? { timeoutMs } : {}),
});
@@ -28,8 +28,9 @@ import {
sortCronJobsByUpdatedAt,
type CronJobSummary,
} from "@/lib/cron/types";
import type { GatewayClient, GatewayStatus } from "@/lib/gateway/GatewayClient";
import { isGatewayDisconnectLikeError } from "@/lib/gateway/GatewayClient";
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
import type { GatewayStatus } from "@/lib/gateway/gateway-status";
import { isGatewayDisconnectLikeError } from "@/lib/gateway/gateway-disconnect";
import type { GatewayModelPolicySnapshot } from "@/lib/gateway/models";
import {
readGatewayAgentSkillsAllowlist,
@@ -45,6 +46,16 @@ import {
type SkillStatusEntry,
type SkillStatusReport,
} from "@/lib/skills/types";
import {
createDomainCronJob,
installDomainSkill,
listDomainCronJobs,
loadDomainSkillStatus,
removeDomainCronJob,
setDomainAgentSkillsAllowlist,
runDomainCronJobNow,
updateDomainSkill,
} from "@/lib/controlplane/domain-runtime-client";
type RestartingMutationBlockState = MutationBlockState & { kind: MutationWorkflowKind };
type SkillSetupMessage = { kind: "success" | "error"; message: string };
@@ -79,6 +90,37 @@ type UseAgentSettingsMutationControllerParams = {
useDomainIntents: boolean;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object" && !Array.isArray(value));
const readAgentSkillsAllowlistFromSnapshot = (
snapshot: GatewayModelPolicySnapshot | null,
agentId: string
): string[] | undefined => {
const normalizedAgentId = agentId.trim();
if (!normalizedAgentId) return undefined;
const configRaw = snapshot?.config;
const config = isRecord(configRaw) ? configRaw : null;
const agentsRaw = config && isRecord(config.agents) ? config.agents : null;
const list = Array.isArray(agentsRaw?.list) ? agentsRaw.list : [];
const entry = list.find((candidate) => {
if (!isRecord(candidate)) return false;
return candidate.id === normalizedAgentId;
});
if (!entry || !isRecord(entry)) {
return undefined;
}
const rawSkills = (entry as Record<string, unknown>).skills;
if (!Array.isArray(rawSkills)) {
return undefined;
}
const values = rawSkills
.filter((value): value is string => typeof value === "string")
.map((value) => value.trim())
.filter((value) => value.length > 0);
return Array.from(new Set(values));
};
export function useAgentSettingsMutationController(params: UseAgentSettingsMutationControllerParams) {
const skillsLoadRequestIdRef = useRef(0);
const [settingsSkillsReport, setSettingsSkillsReport] = useState<SkillStatusReport | null>(null);
@@ -109,7 +151,7 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
const mutationContext: AgentSettingsMutationContext = useMemo(
() => ({
status: params.useDomainIntents ? "connected" : params.status,
status: params.status,
hasCreateBlock: params.hasCreateBlock,
hasRenameBlock: hasRenameMutationBlock,
hasDeleteBlock: hasDeleteMutationBlock,
@@ -125,7 +167,6 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
hasRenameMutationBlock,
params.hasCreateBlock,
params.status,
params.useDomainIntents,
]
);
@@ -160,7 +201,9 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
setSettingsSkillsLoading(true);
setSettingsSkillsError(null);
try {
const report = await loadAgentSkillStatus(params.client, resolvedAgentId);
const report = params.useDomainIntents
? await loadDomainSkillStatus(resolvedAgentId)
: await loadAgentSkillStatus(params.client, resolvedAgentId);
if (requestId !== skillsLoadRequestIdRef.current) {
return;
}
@@ -181,7 +224,7 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
}
}
},
[params.client]
[params.client, params.useDomainIntents]
);
useEffect(() => {
@@ -229,7 +272,9 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
setSettingsCronLoading(true);
setSettingsCronError(null);
try {
const result = await listCronJobs(params.client, { includeDisabled: true });
const result = params.useDomainIntents
? await listDomainCronJobs({ includeDisabled: true })
: await listCronJobs(params.client, { includeDisabled: true });
const filtered = filterCronJobsForAgent(result.jobs, resolvedAgentId);
setSettingsCronJobs(sortCronJobsByUpdatedAt(filtered));
} catch (err) {
@@ -243,7 +288,7 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
setSettingsCronLoading(false);
}
},
[params.client]
[params.client, params.useDomainIntents]
);
useEffect(() => {
@@ -498,6 +543,15 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
onBusyChange: setCronCreateBusy,
onError: setSettingsCronError,
onJobs: setSettingsCronJobs,
deps: params.useDomainIntents
? {
createCronJobForInput: async (input) => {
await createDomainCronJob(input);
},
listCronJobsWithoutClient: async ({ includeDisabled }) =>
await listDomainCronJobs({ includeDisabled }),
}
: undefined,
});
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to create automation.";
@@ -507,7 +561,14 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
throw err;
}
},
[cronCreateBusy, cronDeleteBusyJobId, cronRunBusyJobId, mutationContext, params.client]
[
cronCreateBusy,
cronDeleteBusyJobId,
cronRunBusyJobId,
mutationContext,
params.client,
params.useDomainIntents,
]
);
const handleRunCronJob = useCallback(
@@ -528,7 +589,11 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
setCronRunBusyJobId(resolvedJobId);
setSettingsCronError(null);
try {
await runCronJobNow(params.client, resolvedJobId);
if (params.useDomainIntents) {
await runDomainCronJobNow(resolvedJobId);
} else {
await runCronJobNow(params.client, resolvedJobId);
}
await loadCronJobsForSettingsAgent(resolvedAgentId);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to run schedule.";
@@ -538,7 +603,7 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
setCronRunBusyJobId((current) => (current === resolvedJobId ? null : current));
}
},
[loadCronJobsForSettingsAgent, mutationContext, params.client]
[loadCronJobsForSettingsAgent, mutationContext, params.client, params.useDomainIntents]
);
const handleDeleteCronJob = useCallback(
@@ -559,7 +624,9 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
setCronDeleteBusyJobId(resolvedJobId);
setSettingsCronError(null);
try {
const result = await removeCronJob(params.client, resolvedJobId);
const result = params.useDomainIntents
? await removeDomainCronJob(resolvedJobId)
: await removeCronJob(params.client, resolvedJobId);
if (result.ok && result.removed) {
setSettingsCronJobs((jobs) => jobs.filter((job) => job.id !== resolvedJobId));
}
@@ -572,7 +639,7 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
setCronDeleteBusyJobId((current) => (current === resolvedJobId ? null : current));
}
},
[loadCronJobsForSettingsAgent, mutationContext, params.client]
[loadCronJobsForSettingsAgent, mutationContext, params.client, params.useDomainIntents]
);
const handleRenameAgent = useCallback(
@@ -727,15 +794,22 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
agentId,
decisionKind: "use-all-skills",
run: async (normalizedAgentId) => {
await updateGatewayAgentSkillsAllowlist({
client: params.client,
agentId: normalizedAgentId,
mode: "all",
});
if (params.useDomainIntents) {
await setDomainAgentSkillsAllowlist({
agentId: normalizedAgentId,
mode: "all",
});
} else {
await updateGatewayAgentSkillsAllowlist({
client: params.client,
agentId: normalizedAgentId,
mode: "all",
});
}
},
});
},
[params.client, runSkillsMutation]
[params.client, params.useDomainIntents, runSkillsMutation]
);
const handleDisableAllSkills = useCallback(
@@ -744,15 +818,22 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
agentId,
decisionKind: "disable-all-skills",
run: async (normalizedAgentId) => {
await updateGatewayAgentSkillsAllowlist({
client: params.client,
agentId: normalizedAgentId,
mode: "none",
});
if (params.useDomainIntents) {
await setDomainAgentSkillsAllowlist({
agentId: normalizedAgentId,
mode: "none",
});
} else {
await updateGatewayAgentSkillsAllowlist({
client: params.client,
agentId: normalizedAgentId,
mode: "none",
});
}
},
});
},
[params.client, runSkillsMutation]
[params.client, params.useDomainIntents, runSkillsMutation]
);
const handleSetSkillEnabled = useCallback(
@@ -773,10 +854,15 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
if (visibleSkillNames.length === 0) {
throw new Error("Cannot update skill access: no skills available for this agent.");
}
const existingAllowlist = await readGatewayAgentSkillsAllowlist({
client: params.client,
agentId: normalizedAgentId,
});
const existingAllowlist = params.useDomainIntents
? readAgentSkillsAllowlistFromSnapshot(
params.gatewayConfigSnapshot,
normalizedAgentId
)
: await readGatewayAgentSkillsAllowlist({
client: params.client,
agentId: normalizedAgentId,
});
const baseline = existingAllowlist ?? visibleSkillNames;
const next = new Set(
baseline.map((value) => value.trim()).filter((value) => value.length > 0)
@@ -786,16 +872,25 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
} else {
next.delete(resolvedSkillName);
}
await updateGatewayAgentSkillsAllowlist({
client: params.client,
agentId: normalizedAgentId,
mode: "allowlist",
skillNames: [...next],
});
const nextAllowlist = [...next];
if (params.useDomainIntents) {
await setDomainAgentSkillsAllowlist({
agentId: normalizedAgentId,
mode: "allowlist",
skillNames: nextAllowlist,
});
} else {
await updateGatewayAgentSkillsAllowlist({
client: params.client,
agentId: normalizedAgentId,
mode: "allowlist",
skillNames: nextAllowlist,
});
}
},
});
},
[params.client, runSkillsMutation, settingsSkillsReport]
[params.client, params.gatewayConfigSnapshot, params.useDomainIntents, runSkillsMutation, settingsSkillsReport]
);
const handleSetSkillsAllowlist = useCallback(
@@ -814,16 +909,24 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
if (normalizedSkillNames.length === 0) {
throw new Error("Cannot set selected skills mode: choose at least one skill.");
}
await updateGatewayAgentSkillsAllowlist({
client: params.client,
agentId: normalizedAgentId,
mode: "allowlist",
skillNames: normalizedSkillNames,
});
if (params.useDomainIntents) {
await setDomainAgentSkillsAllowlist({
agentId: normalizedAgentId,
mode: "allowlist",
skillNames: normalizedSkillNames,
});
} else {
await updateGatewayAgentSkillsAllowlist({
client: params.client,
agentId: normalizedAgentId,
mode: "allowlist",
skillNames: normalizedSkillNames,
});
}
},
});
},
[params.client, runSkillsMutation]
[params.client, params.useDomainIntents, runSkillsMutation]
);
const handleSkillApiKeyDraftChange = useCallback((skillKey: string, value: string) => {
@@ -910,18 +1013,24 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
skillKey,
label: `Install dependencies for ${name.trim() || skillKey.trim()}`,
run: async () => {
const result = await installSkill(params.client, {
name,
installId,
timeoutMs: SKILL_INSTALL_TIMEOUT_MS,
});
const result = params.useDomainIntents
? await installDomainSkill({
name,
installId,
timeoutMs: SKILL_INSTALL_TIMEOUT_MS,
})
: await installSkill(params.client, {
name,
installId,
timeoutMs: SKILL_INSTALL_TIMEOUT_MS,
});
return {
successMessage: result.message || "Installed",
};
},
});
},
[SKILL_INSTALL_TIMEOUT_MS, params.client, runSkillSetupMutation]
[SKILL_INSTALL_TIMEOUT_MS, params.client, params.useDomainIntents, runSkillSetupMutation]
);
const handleRemoveSkill = useCallback(
@@ -1000,17 +1109,30 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
label: `Save API key for ${normalizedSkillKey}`,
refreshConfigSnapshot: true,
run: async () => {
await updateSkill(params.client, {
skillKey: normalizedSkillKey,
apiKey,
});
if (params.useDomainIntents) {
await updateDomainSkill({
skillKey: normalizedSkillKey,
apiKey,
});
} else {
await updateSkill(params.client, {
skillKey: normalizedSkillKey,
apiKey,
});
}
return {
successMessage: "API key saved",
};
},
});
},
[params.client, runSkillSetupMutation, setSkillMessage, settingsSkillApiKeyDrafts]
[
params.client,
params.useDomainIntents,
runSkillSetupMutation,
setSkillMessage,
settingsSkillApiKeyDrafts,
]
);
const handleSetSkillGlobalEnabled = useCallback(
@@ -1023,17 +1145,24 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
label: `${enabled ? "Enable" : "Disable"} ${normalizedSkillKey}`,
refreshConfigSnapshot: true,
run: async () => {
await updateSkill(params.client, {
skillKey: normalizedSkillKey,
enabled,
});
if (params.useDomainIntents) {
await updateDomainSkill({
skillKey: normalizedSkillKey,
enabled,
});
} else {
await updateSkill(params.client, {
skillKey: normalizedSkillKey,
enabled,
});
}
return {
successMessage: enabled ? "Skill enabled globally" : "Skill disabled globally",
};
},
});
},
[params.client, runSkillSetupMutation]
[params.client, params.useDomainIntents, runSkillSetupMutation]
);
return {
@@ -10,7 +10,7 @@ import {
import { sendChatMessageViaStudio } from "@/features/agents/operations/chatSendOperation";
import { mergePendingLivePatch } from "@/features/agents/state/livePatchQueue";
import { buildNewSessionAgentPatch, type AgentState } from "@/features/agents/state/store";
import type { GatewayStatus } from "@/lib/gateway/GatewayClient";
import type { GatewayStatus } from "@/lib/gateway/gateway-status";
import type { RuntimeWriteTransport } from "@/features/agents/operations/runtimeWriteTransport";
type ChatInteractionDispatchAction =
@@ -48,7 +48,7 @@ type ChatInteractionController = {
handleSend: (agentId: string, sessionKey: string, message: string) => Promise<void>;
removeQueuedMessage: (agentId: string, index: number) => void;
handleNewSession: (agentId: string) => Promise<void>;
handleStopRun: (agentId: string, sessionKey: string) => Promise<void>;
handleStopRun: (agentId: string, sessionKey: string, runId?: string | null) => Promise<void>;
queueLivePatch: (agentId: string, patch: Partial<AgentState>) => void;
clearPendingLivePatch: (agentId: string) => void;
};
@@ -295,7 +295,7 @@ export function useChatInteractionController(
}, [params.agents, params.status, sendNextQueuedMessage]);
const handleStopRun = useCallback(
async (agentId: string, sessionKey: string) => {
async (agentId: string, sessionKey: string, runId?: string | null) => {
const stopIntent = planStopRunIntent({
status: params.status,
agentId,
@@ -313,8 +313,10 @@ export function useChatInteractionController(
setStopBusyAgentId(agentId);
stopBusyAgentIdRef.current = agentId;
try {
const normalizedRunId = typeof runId === "string" ? runId.trim() : "";
await params.runtimeWriteTransport.chatAbort({
sessionKey: stopIntent.sessionKey,
...(normalizedRunId ? { runId: normalizedRunId } : {}),
});
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to stop run.";
@@ -13,6 +13,7 @@ import {
type GatewayModelPolicySnapshot,
} from "@/lib/gateway/models";
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
import { loadDomainConfigSnapshot, loadDomainModels } from "@/lib/controlplane/domain-runtime-client";
const defaultLogError = (message: string, err: unknown) => {
console.error(message, err);
@@ -21,6 +22,7 @@ const defaultLogError = (message: string, err: unknown) => {
type UseGatewayConfigSyncControllerParams = {
client: GatewayClient;
status: GatewayConnectionStatus;
useDomainApiReads: boolean;
settingsRouteActive: boolean;
inspectSidebarAgentId: string | null;
gatewayConfigSnapshot: GatewayModelPolicySnapshot | null;
@@ -48,6 +50,7 @@ export function useGatewayConfigSyncController(
const {
client,
status,
useDomainApiReads,
settingsRouteActive,
inspectSidebarAgentId,
gatewayConfigSnapshot,
@@ -65,7 +68,9 @@ export function useGatewayConfigSyncController(
const refreshGatewayConfigSnapshot = useCallback(async () => {
if (status !== "connected") return null;
try {
const snapshot = await client.call<GatewayModelPolicySnapshot>("config.get", {});
const snapshot = useDomainApiReads
? await loadDomainConfigSnapshot()
: await client.call<GatewayModelPolicySnapshot>("config.get", {});
setGatewayConfigSnapshot(snapshot);
return snapshot;
} catch (err) {
@@ -74,9 +79,12 @@ export function useGatewayConfigSyncController(
}
return null;
}
}, [client, isDisconnectLikeError, logError, setGatewayConfigSnapshot, status]);
}, [client, isDisconnectLikeError, logError, setGatewayConfigSnapshot, status, useDomainApiReads]);
useEffect(() => {
if (useDomainApiReads) {
return;
}
const repairIntent = resolveSandboxRepairIntent({
status,
attempted: sandboxRepairAttemptedRef.current,
@@ -107,7 +115,7 @@ export function useGatewayConfigSyncController(
await loadAgents();
},
});
}, [client, enqueueConfigMutation, gatewayConfigSnapshot, loadAgents, status]);
}, [client, enqueueConfigMutation, gatewayConfigSnapshot, loadAgents, status, useDomainApiReads]);
useEffect(() => {
if (
@@ -135,7 +143,9 @@ export function useGatewayConfigSyncController(
const loadModels = async () => {
let configSnapshot: GatewayModelPolicySnapshot | null = null;
try {
configSnapshot = await client.call<GatewayModelPolicySnapshot>("config.get", {});
configSnapshot = useDomainApiReads
? await loadDomainConfigSnapshot()
: await client.call<GatewayModelPolicySnapshot>("config.get", {});
if (!cancelled) {
setGatewayConfigSnapshot(configSnapshot);
}
@@ -146,12 +156,12 @@ export function useGatewayConfigSyncController(
}
try {
const result = await client.call<{ models: GatewayModelChoice[] }>(
"models.list",
{}
);
const catalog = useDomainApiReads
? await loadDomainModels()
: (
await client.call<{ models: GatewayModelChoice[] }>("models.list", {})
).models ?? [];
if (cancelled) return;
const catalog = Array.isArray(result.models) ? result.models : [];
setGatewayModels(buildGatewayModelChoices(catalog, configSnapshot));
setGatewayModelsError(null);
} catch (err) {
@@ -177,6 +187,7 @@ export function useGatewayConfigSyncController(
setGatewayModels,
setGatewayModelsError,
status,
useDomainApiReads,
]);
return {
@@ -1,6 +1,6 @@
import { resolveExecApprovalEventEffects, type ExecApprovalEventEffects } from "@/features/agents/approvals/execApprovalLifecycleWorkflow";
import type { AgentState } from "@/features/agents/state/store";
import type { EventFrame } from "@/lib/gateway/GatewayClient";
import type { EventFrame } from "@/lib/gateway/gateway-frames";
import { parseAgentIdFromSessionKey } from "@/lib/gateway/session-keys";
export type CronTranscriptIntent = {
@@ -22,7 +22,7 @@ import {
type RuntimeCoordinatorDispatchAction,
type RuntimeCoordinatorEffectCommand,
} from "@/features/agents/state/runtimeEventCoordinatorWorkflow";
import type { EventFrame } from "@/lib/gateway/GatewayClient";
import type { EventFrame } from "@/lib/gateway/gateway-frames";
import { isSameSessionKey } from "@/lib/gateway/session-keys";
import { normalizeAssistantDisplayText } from "@/lib/text/assistantText";
import {
@@ -277,8 +277,15 @@ export function createGatewayRuntimeEventHandler(
const activeRunId = agent?.runId?.trim() ?? "";
const role = resolveRole(payload.message);
const nowMs = now();
const allowAbortedRunMismatchRecovery =
payload.state === "aborted" && agent?.status === "running";
if (payload.runId && activeRunId && activeRunId !== payload.runId) {
if (
payload.runId &&
activeRunId &&
activeRunId !== payload.runId &&
!allowAbortedRunMismatchRecovery
) {
clearRunTracking(payload.runId);
return;
}
@@ -227,7 +227,10 @@ export const planRuntimeChatEvent = (
isStaleTerminal: chatTerminalDecision?.isStaleTerminal ?? false,
shouldRequestHistoryRefresh,
shouldUpdateLastResult,
shouldSetRunIdle: Boolean(payload.runId && agent?.runId === payload.runId && payload.state !== "error"),
shouldSetRunIdle:
payload.state === "aborted"
? agent?.status === "running"
: Boolean(payload.runId && agent?.runId === payload.runId && payload.state !== "error"),
shouldSetRunError: Boolean(payload.runId && agent?.runId === payload.runId && payload.state === "error"),
lastResultText: shouldUpdateLastResult ? finalAssistantText : null,
assistantCompletionAt: payload.state === "final" ? assistantCompletionAt : null,
@@ -87,7 +87,7 @@ export type SummarySnapshotAgent = {
status?: AgentState["status"];
};
export type SummarySessionStatusEntry = {
type SummarySessionStatusEntry = {
key: string;
updatedAt: number | null;
};
@@ -105,7 +105,7 @@ type SummaryPreviewItem = {
timestamp?: number | string;
};
export type SummaryPreviewEntry = {
type SummaryPreviewEntry = {
key: string;
status: "ok" | "empty" | "missing" | "error";
items: SummaryPreviewItem[];
@@ -114,7 +114,9 @@ export const decideRuntimeChatEvent = (
return intents;
}
if (runId && activeRunId && activeRunId !== runId) {
const allowAbortedRunMismatchRecovery =
input.state === "aborted" && input.agentStatus === "running";
if (runId && activeRunId && activeRunId !== runId && !allowAbortedRunMismatchRecovery) {
return [{ kind: "clearRunTracking", runId }];
}
if (runId && input.isStaleTerminal) {
@@ -1,6 +1,6 @@
export type RuntimeTerminalCommitSource = "chat-final" | "lifecycle-fallback";
type RuntimeTerminalCommitSource = "chat-final" | "lifecycle-fallback";
export type RuntimeTerminalRunState = {
type RuntimeTerminalRunState = {
chatFinalSeen: boolean;
terminalCommitted: boolean;
lastTerminalSeq: number | null;
@@ -1,9 +1,11 @@
import {
isWebchatSessionMutationBlockedError,
syncGatewaySessionSettings,
type GatewayClient,
type GatewaySessionsPatchResult,
} from "@/lib/gateway/GatewayClient";
import { isWebchatSessionMutationBlockedError } from "@/lib/gateway/gateway-disconnect";
import {
syncGatewaySessionSettings,
type GatewaySessionsPatchResult,
} from "@/lib/gateway/session-settings-sync";
import type { RuntimeWriteTransport } from "@/features/agents/operations/runtimeWriteTransport";
type SessionSettingField = "model" | "thinkingLevel";
@@ -0,0 +1,219 @@
import type { AgentFileName } from "@/lib/agents/agentFiles";
import type { GatewayModelChoice, GatewayModelPolicySnapshot } from "@/lib/gateway/models";
import { fetchJson } from "@/lib/http";
import type {
CronJobCreateInput,
CronJobSummary,
CronRunResult,
} from "@/lib/cron/types";
import type { SkillStatusReport } from "@/lib/skills/types";
type Envelope<T> = {
ok?: boolean;
payload?: T;
error?: string;
};
type SessionsListEntry = {
key?: string;
updatedAt?: number | null;
origin?: { label?: string | null } | null;
};
type SessionsListResult = {
sessions?: SessionsListEntry[];
};
type ChatHistoryResult = {
messages?: Record<string, unknown>[];
};
const unwrapPayload = <T>(result: Envelope<T>): T => {
if (result && result.ok === true && "payload" in result) {
return result.payload as T;
}
throw new Error(result?.error ?? "Request failed.");
};
export const loadDomainConfigSnapshot = async (): Promise<GatewayModelPolicySnapshot> => {
const result = await fetchJson<Envelope<GatewayModelPolicySnapshot>>("/api/runtime/config", {
cache: "no-store",
});
return unwrapPayload(result);
};
export const loadDomainModels = async (): Promise<GatewayModelChoice[]> => {
const result = await fetchJson<Envelope<{ models?: GatewayModelChoice[] }>>("/api/runtime/models", {
cache: "no-store",
});
const payload = unwrapPayload(result);
return Array.isArray(payload.models) ? payload.models : [];
};
export const loadDomainSkillStatus = async (agentId: string): Promise<SkillStatusReport> => {
const encodedAgentId = encodeURIComponent(agentId.trim());
const result = await fetchJson<Envelope<SkillStatusReport>>(
`/api/runtime/skills/status?agentId=${encodedAgentId}`,
{ cache: "no-store" }
);
return unwrapPayload(result);
};
export const installDomainSkill = async (params: {
name: string;
installId: string;
timeoutMs?: number;
}): Promise<{ ok: boolean; message: string; stdout: string; stderr: string; code: number | null; warnings?: string[] }> => {
const result = await fetchJson<Envelope<{ ok: boolean; message: string; stdout: string; stderr: string; code: number | null; warnings?: string[] }>>(
"/api/intents/skills-install",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
}
);
return unwrapPayload(result);
};
export const updateDomainSkill = async (params: {
skillKey: string;
enabled?: boolean;
apiKey?: string;
}): Promise<{ ok: boolean; skillKey: string; config: Record<string, unknown> }> => {
const result = await fetchJson<Envelope<{ ok: boolean; skillKey: string; config: Record<string, unknown> }>>(
"/api/intents/skills-update",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
}
);
return unwrapPayload(result);
};
export const setDomainAgentSkillsAllowlist = async (params: {
agentId: string;
mode: "all" | "none" | "allowlist";
skillNames?: string[];
}): Promise<void> => {
const result = await fetchJson<Envelope<{ updated: boolean }>>("/api/intents/agent-skills-allowlist", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
unwrapPayload(result);
};
export const listDomainCronJobs = async (params: {
includeDisabled?: boolean;
} = {}): Promise<{ jobs: CronJobSummary[] }> => {
const includeDisabled = params.includeDisabled ?? true;
const result = await fetchJson<Envelope<{ jobs?: CronJobSummary[] }>>(
`/api/runtime/cron?includeDisabled=${includeDisabled ? "true" : "false"}`,
{ cache: "no-store" }
);
const payload = unwrapPayload(result);
return { jobs: Array.isArray(payload.jobs) ? payload.jobs : [] };
};
export const createDomainCronJob = async (input: CronJobCreateInput): Promise<CronJobSummary> => {
const result = await fetchJson<Envelope<CronJobSummary>>("/api/intents/cron-add", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
return unwrapPayload(result);
};
export const runDomainCronJobNow = async (jobId: string): Promise<CronRunResult> => {
const result = await fetchJson<Envelope<CronRunResult>>("/api/intents/cron-run", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: jobId.trim() }),
});
return unwrapPayload(result);
};
export const removeDomainCronJob = async (jobId: string): Promise<{ ok: true; removed: boolean } | { ok: false; removed: false }> => {
const result = await fetchJson<Envelope<{ ok: true; removed: boolean } | { ok: false; removed: false }>>(
"/api/intents/cron-remove",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: jobId.trim() }),
}
);
return unwrapPayload(result);
};
export const readDomainAgentFile = async (params: {
agentId: string;
name: AgentFileName;
}): Promise<{ exists: boolean; content: string }> => {
const query = new URLSearchParams({
agentId: params.agentId.trim(),
name: params.name,
});
const result = await fetchJson<Envelope<{ file?: { missing?: unknown; content?: unknown } }>>(
`/api/runtime/agent-file?${query.toString()}`,
{ cache: "no-store" }
);
const payload = unwrapPayload(result);
const file = payload?.file;
const record = file && typeof file === "object" ? (file as Record<string, unknown>) : null;
const missing = record?.missing === true;
const content = typeof record?.content === "string" ? record.content : "";
return { exists: !missing, content };
};
export const writeDomainAgentFile = async (params: {
agentId: string;
name: AgentFileName;
content: string;
}): Promise<void> => {
const result = await fetchJson<Envelope<unknown>>("/api/intents/agent-file-set", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
unwrapPayload(result);
};
export const listDomainSessions = async (params: {
agentId: string;
includeGlobal?: boolean;
includeUnknown?: boolean;
search?: string;
limit?: number;
}): Promise<SessionsListResult> => {
const query = new URLSearchParams();
query.set("agentId", params.agentId.trim());
query.set("includeGlobal", params.includeGlobal === true ? "true" : "false");
query.set("includeUnknown", params.includeUnknown === true ? "true" : "false");
if (params.search?.trim()) {
query.set("search", params.search.trim());
}
if (typeof params.limit === "number" && Number.isFinite(params.limit) && params.limit > 0) {
query.set("limit", String(Math.floor(params.limit)));
}
const result = await fetchJson<Envelope<SessionsListResult>>(`/api/runtime/sessions?${query.toString()}`, {
cache: "no-store",
});
return unwrapPayload(result);
};
export const loadDomainChatHistory = async (params: {
sessionKey: string;
limit?: number;
}): Promise<ChatHistoryResult> => {
const query = new URLSearchParams({ sessionKey: params.sessionKey.trim() });
if (typeof params.limit === "number" && Number.isFinite(params.limit) && params.limit > 0) {
query.set("limit", String(Math.floor(params.limit)));
}
const result = await fetchJson<Envelope<ChatHistoryResult>>(
`/api/runtime/chat-history?${query.toString()}`,
{ cache: "no-store" }
);
return unwrapPayload(result);
};
+10 -1
View File
@@ -18,24 +18,33 @@ const MAX_RECONNECT_DELAY_MS = 15_000;
const CONNECT_PROTOCOL = 3;
const CONNECT_CLIENT_ID = "openclaw-control-ui";
const CONNECT_CLIENT_MODE = "webchat";
const CONNECT_CAPABILITIES = ["tool-events"];
const DEFAULT_METHOD_ALLOWLIST = new Set<string>([
"status",
"chat.send",
"chat.abort",
"chat.history",
"agents.create",
"agents.update",
"agents.delete",
"agents.list",
"agents.files.get",
"agents.files.set",
"sessions.list",
"sessions.preview",
"sessions.patch",
"sessions.reset",
"cron.list",
"cron.run",
"cron.remove",
"cron.add",
"config.get",
"config.set",
"models.list",
"skills.status",
"skills.install",
"skills.update",
"exec.approval.resolve",
"exec.approvals.get",
"exec.approvals.set",
@@ -330,7 +339,7 @@ export class OpenClawGatewayAdapter {
"operator.approvals",
"operator.pairing",
],
caps: [],
caps: CONNECT_CAPABILITIES,
auth: { token },
},
})
+32 -17
View File
@@ -1,7 +1,8 @@
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import Database from "better-sqlite3";
import type BetterSqlite3 from "better-sqlite3";
import type {
ControlPlaneDomainEvent,
@@ -11,6 +12,8 @@ import type {
import { deriveControlPlaneEventKey } from "@/lib/controlplane/outbox";
import { resolveStateDir } from "@/lib/clawdbot/paths";
const require = createRequire(import.meta.url);
const RUNTIME_DB_DIRNAME = "openclaw-studio";
const RUNTIME_DB_FILENAME = "runtime.db";
@@ -41,6 +44,14 @@ type OutboxColumnInfo = {
name: string;
};
type BetterSqlite3Factory = typeof BetterSqlite3;
type BetterSqlite3Database = BetterSqlite3.Database;
type BetterSqlite3Statement<BindParams extends unknown[] = unknown[], Result = unknown> =
BetterSqlite3.Statement<BindParams, Result>;
const loadBetterSqlite3 = (): BetterSqlite3Factory =>
require("better-sqlite3") as BetterSqlite3Factory;
const parseDomainEvent = (raw: string): ControlPlaneDomainEvent => {
return JSON.parse(raw) as ControlPlaneDomainEvent;
};
@@ -92,23 +103,26 @@ export type BackfillAgentOutboxResult = {
};
export class SQLiteControlPlaneProjectionStore {
private readonly db: Database.Database;
private readonly readProjectionStmt: Database.Statement<[], ProjectionRow | undefined>;
private readonly readOutboxHeadStmt: Database.Statement<[], { head: number }>;
private readonly readOutboxAfterStmt: Database.Statement<[number, number], OutboxRow>;
private readonly readOutboxBeforeStmt: Database.Statement<[number, number], OutboxRow>;
private readonly readAgentOutboxBeforeStmt: Database.Statement<[string, number, number], OutboxRow>;
private readonly readOutboxByIdStmt: Database.Statement<[number], OutboxRow | undefined>;
private readonly readBackfillCandidatesStmt: Database.Statement<[number, number], LegacyBackfillRow>;
private readonly readProcessedStmt: Database.Statement<[string], { outbox_id: number | null } | undefined>;
private readonly insertProcessedStmt: Database.Statement<[string, string]>;
private readonly insertOutboxStmt: Database.Statement<[string, string, string, string]>;
private readonly updateProcessedOutboxStmt: Database.Statement<[number, string]>;
private readonly updateOutboxAgentIdIfNullStmt: Database.Statement<[string, number]>;
private readonly upsertStatusProjectionStmt: Database.Statement<
private readonly db: BetterSqlite3Database;
private readonly readProjectionStmt: BetterSqlite3Statement<[], ProjectionRow | undefined>;
private readonly readOutboxHeadStmt: BetterSqlite3Statement<[], { head: number }>;
private readonly readOutboxAfterStmt: BetterSqlite3Statement<[number, number], OutboxRow>;
private readonly readOutboxBeforeStmt: BetterSqlite3Statement<[number, number], OutboxRow>;
private readonly readAgentOutboxBeforeStmt: BetterSqlite3Statement<[string, number, number], OutboxRow>;
private readonly readOutboxByIdStmt: BetterSqlite3Statement<[number], OutboxRow | undefined>;
private readonly readBackfillCandidatesStmt: BetterSqlite3Statement<[number, number], LegacyBackfillRow>;
private readonly readProcessedStmt: BetterSqlite3Statement<
[string],
{ outbox_id: number | null } | undefined
>;
private readonly insertProcessedStmt: BetterSqlite3Statement<[string, string]>;
private readonly insertOutboxStmt: BetterSqlite3Statement<[string, string, string, string]>;
private readonly updateProcessedOutboxStmt: BetterSqlite3Statement<[number, string]>;
private readonly updateOutboxAgentIdIfNullStmt: BetterSqlite3Statement<[string, number]>;
private readonly upsertStatusProjectionStmt: BetterSqlite3Statement<
[string, string | null, string, string]
>;
private readonly upsertGatewayProjectionStmt: Database.Statement<[string, string]>;
private readonly upsertGatewayProjectionStmt: BetterSqlite3Statement<[string, string]>;
private readonly applyEventTx: (
event: ControlPlaneDomainEvent,
eventKey: string
@@ -120,7 +134,8 @@ export class SQLiteControlPlaneProjectionStore {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
this.db = new Database(dbPath);
const BetterSqlite3 = loadBetterSqlite3();
this.db = new BetterSqlite3(dbPath);
this.db.pragma("journal_mode = WAL");
this.db.pragma("foreign_keys = ON");
this.migrate();
@@ -0,0 +1,65 @@
import { NextResponse } from "next/server";
import { ControlPlaneGatewayError } from "@/lib/controlplane/openclaw-adapter";
import { serializeRuntimeInitFailure } from "@/lib/controlplane/runtime-init-errors";
import { bootstrapDomainRuntime } from "@/lib/controlplane/runtime-route-bootstrap";
const mapGatewayError = (error: unknown): NextResponse => {
if (error instanceof ControlPlaneGatewayError) {
if (error.code.trim().toUpperCase() === "GATEWAY_UNAVAILABLE") {
return NextResponse.json(
{
error: error.message,
code: "GATEWAY_UNAVAILABLE",
reason: "gateway_unavailable",
},
{ status: 503 }
);
}
return NextResponse.json(
{
error: error.message,
code: error.code,
details: error.details,
},
{ status: 400 }
);
}
const message = error instanceof Error ? error.message : "runtime_read_failed";
return NextResponse.json({ error: message }, { status: 500 });
};
export const executeRuntimeGatewayRead = async <T>(method: string, params: unknown) => {
const bootstrap = await bootstrapDomainRuntime();
if (bootstrap.kind === "mode-disabled") {
return NextResponse.json({ enabled: false, error: "domain_api_mode_disabled" }, { status: 404 });
}
if (bootstrap.kind === "runtime-init-failed") {
return NextResponse.json(
{
enabled: true,
...serializeRuntimeInitFailure(bootstrap.failure),
},
{ status: 503 }
);
}
if (bootstrap.kind === "start-failed") {
return NextResponse.json(
{
enabled: true,
error: bootstrap.message,
code: "GATEWAY_UNAVAILABLE",
reason: "gateway_unavailable",
},
{ status: 503 }
);
}
try {
const payload = await bootstrap.runtime.callGateway<T>(method, params);
return NextResponse.json({ ok: true, payload });
} catch (error) {
return mapGatewayError(error);
}
};
+18 -16
View File
@@ -9,20 +9,6 @@ import {
type BackfillAgentOutboxResult,
} from "@/lib/controlplane/projection-store";
const DOMAIN_MODE_FALSE_VALUES = new Set(["0", "false", "no", "off"]);
const readDomainModeRawValue = (env: NodeJS.ProcessEnv = process.env): string => {
const serverValue = env.STUDIO_DOMAIN_API_MODE?.trim().toLowerCase() ?? "";
if (serverValue) return serverValue;
return env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE?.trim().toLowerCase() ?? "";
};
const readDomainApiMode = (env: NodeJS.ProcessEnv = process.env): boolean => {
const raw = readDomainModeRawValue(env);
if (!raw) return true;
return !DOMAIN_MODE_FALSE_VALUES.has(raw);
};
type ControlPlaneRuntimeOptions = {
adapterOptions?: OpenClawAdapterOptions;
dbPath?: string;
@@ -49,6 +35,16 @@ export class ControlPlaneRuntime {
await this.adapter.stop();
}
connectionStatus() {
return this.adapter.getStatus();
}
async reconnectForGatewaySettingsChange(): Promise<void> {
if (this.adapter.getStatus() === "stopped") return;
await this.adapter.stop();
await this.adapter.start();
}
snapshot(): ControlPlaneRuntimeSnapshot {
return this.store.snapshot();
}
@@ -108,10 +104,16 @@ export const getControlPlaneRuntime = (options?: ControlPlaneRuntimeOptions): Co
return globalState.__openclawStudioControlPlaneRuntime;
};
export const peekControlPlaneRuntime = (): ControlPlaneRuntime | null => {
const globalState = globalThis as GlobalControlPlaneState;
return globalState.__openclawStudioControlPlaneRuntime ?? null;
};
export const resetControlPlaneRuntimeForTests = (): void => {
const globalState = globalThis as GlobalControlPlaneState;
delete globalState.__openclawStudioControlPlaneRuntime;
};
export const isStudioDomainApiModeEnabled = (env: NodeJS.ProcessEnv = process.env): boolean =>
readDomainApiMode(env);
export const isStudioDomainApiModeEnabled = (): boolean => {
return true;
};
+1 -1
View File
@@ -31,7 +31,7 @@ export type CronPayload =
bestEffortDeliver?: boolean;
};
export type CronJobState = {
type CronJobState = {
nextRunAtMs?: number;
runningAtMs?: number;
lastRunAtMs?: number;
+8 -716
View File
@@ -1,720 +1,12 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
GatewayBrowserClient,
type GatewayHelloOk,
} from "./openclaw/GatewayBrowserClient";
import type {
StudioGatewaySettings,
StudioSettings,
StudioSettingsPatch,
} from "@/lib/studio/settings";
import type { StudioSettingsResponse } from "@/lib/studio/coordinator";
import { resolveStudioProxyGatewayUrl } from "@/lib/gateway/proxy-url";
import { ensureGatewayReloadModeHotForLocalStudio } from "@/lib/gateway/gatewayReloadMode";
import { GatewayResponseError } from "@/lib/gateway/errors";
import {
buildAgentMainSessionKey,
parseAgentIdFromSessionKey,
isSameSessionKey,
} from "@/lib/gateway/session-keys";
type ReqFrame = {
type: "req";
id: string;
method: string;
params: unknown;
};
type ResFrame = {
type: "res";
id: string;
ok: boolean;
payload?: unknown;
error?: {
code: string;
message: string;
details?: unknown;
retryable?: boolean;
retryAfterMs?: number;
};
};
export type GatewayStateVersion = {
presence: number;
health: number;
};
export type EventFrame = {
type: "event";
event: string;
payload?: unknown;
seq?: number;
stateVersion?: GatewayStateVersion;
};
type GatewayFrame = ReqFrame | ResFrame | EventFrame;
export const parseGatewayFrame = (raw: string): GatewayFrame | null => {
try {
return JSON.parse(raw) as GatewayFrame;
} catch {
return null;
}
};
export { buildAgentMainSessionKey, parseAgentIdFromSessionKey, isSameSessionKey };
const CONNECT_FAILED_CLOSE_CODE = 4008;
const parseConnectFailedCloseReason = (
reason: string
): { code: string; message: string } | null => {
const trimmed = reason.trim();
if (!trimmed.toLowerCase().startsWith("connect failed:")) return null;
const remainder = trimmed.slice("connect failed:".length).trim();
if (!remainder) return null;
const idx = remainder.indexOf(" ");
const code = (idx === -1 ? remainder : remainder.slice(0, idx)).trim();
if (!code) return null;
const message = (idx === -1 ? "" : remainder.slice(idx + 1)).trim();
return { code, message: message || "connect failed" };
};
const DEFAULT_UPSTREAM_GATEWAY_URL =
process.env.NEXT_PUBLIC_GATEWAY_URL || "ws://localhost:18789";
const normalizeLocalGatewayDefaults = (value: unknown): StudioGatewaySettings | null => {
if (!value || typeof value !== "object") return null;
const raw = value as { url?: unknown; token?: unknown };
const url = typeof raw.url === "string" ? raw.url.trim() : "";
const token = typeof raw.token === "string" ? raw.token.trim() : "";
if (!url) return null;
return { url, token };
};
type StatusHandler = (status: GatewayStatus) => void;
type EventHandler = (event: EventFrame) => void;
export type GatewayGapInfo = { expected: number; received: number };
type GapHandler = (info: GatewayGapInfo) => void;
export type GatewayStatus = "disconnected" | "connecting" | "connected";
type GatewayConnectOptions = {
gatewayUrl: string;
token?: string;
authScopeKey?: string;
clientName?: string;
disableDeviceAuth?: boolean;
};
import type { EventFrame } from "@/lib/gateway/gateway-frames";
import type { GatewayGapInfo } from "@/lib/gateway/gateway-status";
export type { EventFrame } from "@/lib/gateway/gateway-frames";
export type { GatewayGapInfo } from "@/lib/gateway/gateway-status";
export { GatewayResponseError } from "@/lib/gateway/errors";
;
export class GatewayClient {
private client: GatewayBrowserClient | null = null;
private statusHandlers = new Set<StatusHandler>();
private eventHandlers = new Set<EventHandler>();
private gapHandlers = new Set<GapHandler>();
private status: GatewayStatus = "disconnected";
private pendingConnect: Promise<void> | null = null;
private resolveConnect: (() => void) | null = null;
private rejectConnect: ((error: Error) => void) | null = null;
private manualDisconnect = false;
private lastHello: GatewayHelloOk | null = null;
onStatus(handler: StatusHandler) {
this.statusHandlers.add(handler);
handler(this.status);
return () => {
this.statusHandlers.delete(handler);
};
}
onEvent(handler: EventHandler) {
this.eventHandlers.add(handler);
return () => {
this.eventHandlers.delete(handler);
};
}
onGap(handler: GapHandler) {
this.gapHandlers.add(handler);
return () => {
this.gapHandlers.delete(handler);
};
}
async connect(options: GatewayConnectOptions) {
if (!options.gatewayUrl.trim()) {
throw new Error("Gateway URL is required.");
}
if (this.client) {
throw new Error("Gateway is already connected or connecting.");
}
this.manualDisconnect = false;
this.updateStatus("connecting");
this.pendingConnect = new Promise<void>((resolve, reject) => {
this.resolveConnect = resolve;
this.rejectConnect = reject;
});
const nextClient = new GatewayBrowserClient({
url: options.gatewayUrl,
token: options.token,
authScopeKey: options.authScopeKey,
clientName: options.clientName,
disableDeviceAuth: options.disableDeviceAuth,
onHello: (hello) => {
if (this.client !== nextClient) return;
this.lastHello = hello;
this.updateStatus("connected");
this.resolveConnect?.();
this.clearConnectPromise();
},
onEvent: (event) => {
if (this.client !== nextClient) return;
this.eventHandlers.forEach((handler) => handler(event));
},
onClose: ({ code, reason }) => {
if (this.client !== nextClient) return;
const connectFailed =
code === CONNECT_FAILED_CLOSE_CODE ? parseConnectFailedCloseReason(reason) : null;
const err = connectFailed
? new GatewayResponseError({
code: connectFailed.code,
message: connectFailed.message,
})
: new Error(`Gateway closed (${code}): ${reason}`);
if (this.rejectConnect) {
this.rejectConnect(err);
this.clearConnectPromise();
}
if (!this.manualDisconnect) {
nextClient.stop();
}
if (this.client === nextClient) {
this.client = null;
}
this.updateStatus("disconnected");
if (this.manualDisconnect) {
console.info("Gateway disconnected.");
}
},
onGap: ({ expected, received }) => {
if (this.client !== nextClient) return;
this.gapHandlers.forEach((handler) => handler({ expected, received }));
},
});
this.client = nextClient;
nextClient.start();
try {
await this.pendingConnect;
} catch (err) {
const activeClient = this.client;
activeClient?.stop();
if (this.client === activeClient) {
this.client = null;
}
this.updateStatus("disconnected");
throw err;
}
}
disconnect() {
if (!this.client) {
return;
}
this.manualDisconnect = true;
this.client.stop();
this.client = null;
this.clearConnectPromise();
this.updateStatus("disconnected");
console.info("Gateway disconnected.");
}
async call<T = unknown>(method: string, params: unknown): Promise<T> {
if (!method.trim()) {
throw new Error("Gateway method is required.");
}
if (!this.client || !this.client.connected) {
throw new Error("Gateway is not connected.");
}
const payload = await this.client.request<T>(method, params);
return payload as T;
}
getLastHello() {
return this.lastHello;
}
private updateStatus(status: GatewayStatus) {
this.status = status;
this.statusHandlers.forEach((handler) => handler(status));
}
private clearConnectPromise() {
this.pendingConnect = null;
this.resolveConnect = null;
this.rejectConnect = null;
}
}
export const isGatewayDisconnectLikeError = (err: unknown): boolean => {
if (!(err instanceof Error)) return false;
const msg = err.message.toLowerCase();
if (!msg) return false;
if (
msg.includes("gateway not connected") ||
msg.includes("gateway is not connected") ||
msg.includes("gateway client stopped")
) {
return true;
}
const match = msg.match(/gateway closed \\((\\d+)\\)/);
if (!match) return false;
const code = Number(match[1]);
return Number.isFinite(code) && code === 1012;
};
const WEBCHAT_SESSION_MUTATION_BLOCKED_RE = /webchat clients cannot (patch|delete) sessions/i;
const WEBCHAT_SESSION_MUTATION_HINT_RE = /use chat\.send for session-scoped updates/i;
export const isWebchatSessionMutationBlockedError = (error: unknown): boolean => {
if (!(error instanceof GatewayResponseError)) return false;
if (error.code.trim().toUpperCase() !== "INVALID_REQUEST") return false;
const message = error.message.trim();
if (!message) return false;
return (
WEBCHAT_SESSION_MUTATION_BLOCKED_RE.test(message) &&
WEBCHAT_SESSION_MUTATION_HINT_RE.test(message)
);
};
type SessionSettingsPatchPayload = {
key: string;
model?: string | null;
thinkingLevel?: string | null;
execHost?: "sandbox" | "gateway" | "node" | null;
execSecurity?: "deny" | "allowlist" | "full" | null;
execAsk?: "off" | "on-miss" | "always" | null;
};
export type GatewaySessionsPatchResult = {
ok: true;
key: string;
entry?: {
thinkingLevel?: string;
};
resolved?: {
modelProvider?: string;
model?: string;
};
};
type SyncGatewaySessionSettingsParams = {
client: GatewayClient;
sessionKey: string;
model?: string | null;
thinkingLevel?: string | null;
execHost?: "sandbox" | "gateway" | "node" | null;
execSecurity?: "deny" | "allowlist" | "full" | null;
execAsk?: "off" | "on-miss" | "always" | null;
};
export const syncGatewaySessionSettings = async ({
client,
sessionKey,
model,
thinkingLevel,
execHost,
execSecurity,
execAsk,
}: SyncGatewaySessionSettingsParams) => {
const key = sessionKey.trim();
if (!key) {
throw new Error("Session key is required.");
}
const includeModel = model !== undefined;
const includeThinkingLevel = thinkingLevel !== undefined;
const includeExecHost = execHost !== undefined;
const includeExecSecurity = execSecurity !== undefined;
const includeExecAsk = execAsk !== undefined;
if (
!includeModel &&
!includeThinkingLevel &&
!includeExecHost &&
!includeExecSecurity &&
!includeExecAsk
) {
throw new Error("At least one session setting must be provided.");
}
const payload: SessionSettingsPatchPayload = { key };
if (includeModel) {
payload.model = model ?? null;
}
if (includeThinkingLevel) {
payload.thinkingLevel = thinkingLevel ?? null;
}
if (includeExecHost) {
payload.execHost = execHost ?? null;
}
if (includeExecSecurity) {
payload.execSecurity = execSecurity ?? null;
}
if (includeExecAsk) {
payload.execAsk = execAsk ?? null;
}
return await client.call<GatewaySessionsPatchResult>("sessions.patch", payload);
};
const doctorFixHint =
"Run `npx openclaw doctor --fix` on the gateway host (or `pnpm openclaw doctor --fix` in a source checkout).";
const formatGatewayError = (error: unknown) => {
if (error instanceof GatewayResponseError) {
if (error.code === "INVALID_REQUEST" && /invalid config/i.test(error.message)) {
return `Gateway error (${error.code}): ${error.message}. ${doctorFixHint}`;
}
return `Gateway error (${error.code}): ${error.message}`;
}
if (error instanceof Error) {
return error.message;
}
return "Unknown gateway error.";
};
type GatewayConnectionState = {
client: GatewayClient;
status: GatewayStatus;
gatewayUrl: string;
token: string;
localGatewayDefaults: StudioGatewaySettings | null;
domainApiModeEnabled: boolean | null;
error: string | null;
connect: () => Promise<void>;
disconnect: () => void;
useLocalGatewayDefaults: () => void;
setGatewayUrl: (value: string) => void;
setToken: (value: string) => void;
clearError: () => void;
};
type StudioSettingsCoordinatorLike = {
loadSettings: () => Promise<StudioSettings | null>;
loadSettingsEnvelope?: () => Promise<StudioSettingsResponse>;
schedulePatch: (patch: StudioSettingsPatch, debounceMs?: number) => void;
flushPending: () => Promise<void>;
};
const isAuthError = (errorMessage: string | null): boolean => {
if (!errorMessage) return false;
const lower = errorMessage.toLowerCase();
return (
lower.includes("auth") ||
lower.includes("unauthorized") ||
lower.includes("forbidden") ||
lower.includes("invalid token") ||
lower.includes("token required") ||
(lower.includes("token") && lower.includes("not configured")) ||
lower.includes("gateway_token_missing")
);
};
const MAX_AUTO_RETRY_ATTEMPTS = 20;
const INITIAL_RETRY_DELAY_MS = 2_000;
const MAX_RETRY_DELAY_MS = 30_000;
const NON_RETRYABLE_CONNECT_ERROR_CODES = new Set([
"studio.gateway_url_missing",
"studio.gateway_token_missing",
"studio.gateway_url_invalid",
"studio.settings_load_failed",
]);
const isNonRetryableConnectErrorCode = (code: string | null): boolean => {
const normalized = code?.trim().toLowerCase() ?? "";
if (!normalized) return false;
return NON_RETRYABLE_CONNECT_ERROR_CODES.has(normalized);
};
export const resolveGatewayAutoRetryDelayMs = (params: {
status: GatewayStatus;
didAutoConnect: boolean;
wasManualDisconnect: boolean;
gatewayUrl: string;
errorMessage: string | null;
connectErrorCode: string | null;
attempt: number;
}): number | null => {
if (params.status !== "disconnected") return null;
if (!params.didAutoConnect) return null;
if (params.wasManualDisconnect) return null;
if (!params.gatewayUrl.trim()) return null;
if (params.attempt >= MAX_AUTO_RETRY_ATTEMPTS) return null;
if (isNonRetryableConnectErrorCode(params.connectErrorCode)) return null;
if (params.connectErrorCode === null && isAuthError(params.errorMessage)) return null;
return Math.min(
INITIAL_RETRY_DELAY_MS * Math.pow(1.5, params.attempt),
MAX_RETRY_DELAY_MS
);
};
export const useGatewayConnection = (
settingsCoordinator: StudioSettingsCoordinatorLike
): GatewayConnectionState => {
const [client] = useState(() => new GatewayClient());
const didAutoConnect = useRef(false);
const loadedGatewaySettings = useRef<{ gatewayUrl: string } | null>(null);
const retryAttemptRef = useRef(0);
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const wasManualDisconnectRef = useRef(false);
const tokenDirtyRef = useRef(false);
const [gatewayUrl, setGatewayUrl] = useState(DEFAULT_UPSTREAM_GATEWAY_URL);
const [token, setTokenState] = useState("");
const [localGatewayDefaults, setLocalGatewayDefaults] = useState<StudioGatewaySettings | null>(
null
);
const [domainApiModeEnabled, setDomainApiModeEnabled] = useState<boolean | null>(null);
const [status, setStatus] = useState<GatewayStatus>("disconnected");
const [error, setError] = useState<string | null>(null);
const [connectErrorCode, setConnectErrorCode] = useState<string | null>(null);
const [settingsLoaded, setSettingsLoaded] = useState(false);
useEffect(() => {
let cancelled = false;
const loadSettings = async () => {
try {
const envelope =
typeof settingsCoordinator.loadSettingsEnvelope === "function"
? await settingsCoordinator.loadSettingsEnvelope()
: { settings: await settingsCoordinator.loadSettings(), localGatewayDefaults: null };
const settings = envelope.settings ?? null;
const gateway = settings?.gateway ?? null;
if (cancelled) return;
setLocalGatewayDefaults(normalizeLocalGatewayDefaults(envelope.localGatewayDefaults));
const envelopeDomainMode =
"domainApiModeEnabled" in envelope ? envelope.domainApiModeEnabled : undefined;
setDomainApiModeEnabled(
typeof envelopeDomainMode === "boolean" ? envelopeDomainMode : null
);
const nextGatewayUrl = gateway?.url?.trim() ? gateway.url : DEFAULT_UPSTREAM_GATEWAY_URL;
const nextToken = typeof gateway?.token === "string" ? gateway.token : "";
loadedGatewaySettings.current = {
gatewayUrl: nextGatewayUrl.trim(),
};
setGatewayUrl(nextGatewayUrl);
setTokenState(nextToken);
tokenDirtyRef.current = false;
} catch (err) {
if (!cancelled) {
const message = err instanceof Error ? err.message : "Failed to load gateway settings.";
setError(message);
}
} finally {
if (!cancelled) {
if (!loadedGatewaySettings.current) {
loadedGatewaySettings.current = {
gatewayUrl: DEFAULT_UPSTREAM_GATEWAY_URL.trim(),
};
}
setSettingsLoaded(true);
}
}
};
void loadSettings();
return () => {
cancelled = true;
};
}, [settingsCoordinator]);
useEffect(() => {
return client.onStatus((nextStatus) => {
setStatus(nextStatus);
if (nextStatus !== "connecting") {
setError(null);
if (nextStatus === "connected") {
setConnectErrorCode(null);
}
}
});
}, [client]);
useEffect(() => {
return () => {
if (retryTimerRef.current) {
clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
}
client.disconnect();
};
}, [client]);
const connect = useCallback(async () => {
setError(null);
setConnectErrorCode(null);
wasManualDisconnectRef.current = false;
try {
await settingsCoordinator.flushPending();
await client.connect({
gatewayUrl: resolveStudioProxyGatewayUrl(),
token,
authScopeKey: gatewayUrl,
clientName: "openclaw-control-ui",
disableDeviceAuth: true,
});
await ensureGatewayReloadModeHotForLocalStudio({
client,
upstreamGatewayUrl: gatewayUrl,
});
retryAttemptRef.current = 0;
} catch (err) {
setConnectErrorCode(err instanceof GatewayResponseError ? err.code : null);
setError(formatGatewayError(err));
}
}, [client, gatewayUrl, settingsCoordinator, token]);
useEffect(() => {
if (domainApiModeEnabled === true) return;
if (didAutoConnect.current) return;
if (!settingsLoaded) return;
if (!gatewayUrl.trim()) return;
didAutoConnect.current = true;
void connect();
}, [connect, domainApiModeEnabled, gatewayUrl, settingsLoaded]);
// Auto-retry on disconnect (gateway busy, network blip, etc.)
useEffect(() => {
if (domainApiModeEnabled === true) {
if (retryTimerRef.current) {
clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
}
return;
}
const attempt = retryAttemptRef.current;
const delay = resolveGatewayAutoRetryDelayMs({
status,
didAutoConnect: didAutoConnect.current,
wasManualDisconnect: wasManualDisconnectRef.current,
gatewayUrl,
errorMessage: error,
connectErrorCode,
attempt,
});
if (delay === null) return;
retryTimerRef.current = setTimeout(() => {
retryAttemptRef.current = attempt + 1;
void connect();
}, delay);
return () => {
if (retryTimerRef.current) {
clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
}
};
}, [connect, connectErrorCode, domainApiModeEnabled, error, gatewayUrl, status]);
useEffect(() => {
if (domainApiModeEnabled !== true) return;
if (retryTimerRef.current) {
clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
}
retryAttemptRef.current = 0;
didAutoConnect.current = false;
setError(null);
setConnectErrorCode(null);
if (status === "disconnected") return;
wasManualDisconnectRef.current = true;
client.disconnect();
}, [client, domainApiModeEnabled, status]);
// Reset retry count on successful connection
useEffect(() => {
if (status === "connected") {
retryAttemptRef.current = 0;
}
}, [status]);
useEffect(() => {
if (!settingsLoaded) return;
const baseline = loadedGatewaySettings.current;
if (!baseline) return;
const nextGatewayUrl = gatewayUrl.trim();
const shouldPersistToken = tokenDirtyRef.current;
if (!shouldPersistToken && nextGatewayUrl === baseline.gatewayUrl) {
return;
}
const gatewayPatch: { url: string; token?: string } = { url: nextGatewayUrl };
if (shouldPersistToken) {
gatewayPatch.token = token;
tokenDirtyRef.current = false;
}
settingsCoordinator.schedulePatch(
{
gateway: gatewayPatch,
},
400
);
loadedGatewaySettings.current = { gatewayUrl: nextGatewayUrl };
}, [gatewayUrl, settingsCoordinator, settingsLoaded, token]);
const useLocalGatewayDefaults = useCallback(() => {
if (!localGatewayDefaults) {
return;
}
setGatewayUrl(localGatewayDefaults.url);
setTokenState(localGatewayDefaults.token);
tokenDirtyRef.current = false;
setError(null);
setConnectErrorCode(null);
}, [localGatewayDefaults]);
const setToken = useCallback((value: string) => {
tokenDirtyRef.current = true;
setTokenState(value);
}, []);
const disconnect = useCallback(() => {
setError(null);
setConnectErrorCode(null);
wasManualDisconnectRef.current = true;
client.disconnect();
}, [client]);
const clearError = useCallback(() => {
setError(null);
setConnectErrorCode(null);
}, []);
return {
client,
status,
gatewayUrl,
token,
localGatewayDefaults,
domainApiModeEnabled,
error,
connect,
disconnect,
useLocalGatewayDefaults,
setGatewayUrl,
setToken,
clearError,
};
export type GatewayClient = {
call: <T = unknown>(method: string, params: unknown) => Promise<T>;
onEvent?: (handler: (event: EventFrame) => void) => () => void;
onGap?: (handler: (info: GatewayGapInfo) => void) => () => void;
};
+39 -18
View File
@@ -53,6 +53,20 @@ const DEFAULT_ACK_MAX_CHARS = 300;
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object" && !Array.isArray(value));
const callGateway = async <T>(
client: GatewayClient,
method: string,
params: unknown
): Promise<T> => {
const invoke = (
client as unknown as { call?: (nextMethod: string, nextParams: unknown) => Promise<unknown> }
).call;
if (typeof invoke !== "function") {
throw new Error("Legacy gateway client call transport is unavailable.");
}
return (await invoke(method, params)) as T;
};
export type ConfigAgentEntry = Record<string, unknown> & { id: string };
type GatewayAgentSandboxOverrides = {
@@ -272,8 +286,8 @@ export const listHeartbeatsForAgent = async (
): Promise<HeartbeatListResult> => {
const resolvedAgentId = resolveHeartbeatAgentId(agentId);
const [snapshot, status] = await Promise.all([
client.call<GatewayConfigSnapshot>("config.get", {}),
client.call<GatewayStatusSnapshot>("status", {}),
callGateway<GatewayConfigSnapshot>(client, "config.get", {}),
callGateway<GatewayStatusSnapshot>(client, "status", {}),
]);
const config = isRecord(snapshot.config) ? snapshot.config : {};
const resolved = resolveHeartbeatSettings(config, resolvedAgentId);
@@ -302,7 +316,7 @@ export const triggerHeartbeatNow = async (
agentId: string
): Promise<HeartbeatWakeResult> => {
const resolvedAgentId = resolveHeartbeatAgentId(agentId);
return client.call<HeartbeatWakeResult>("wake", {
return callGateway<HeartbeatWakeResult>(client, "wake", {
mode: "now",
text: `OpenClaw Studio heartbeat trigger (${resolvedAgentId}).`,
});
@@ -331,10 +345,10 @@ const applyGatewayConfigPatch = async (params: {
};
if (baseHash) payload.baseHash = baseHash;
try {
await params.client.call("config.patch", payload);
await callGateway(params.client, "config.patch", payload);
} catch (err) {
if (attempt < 1 && shouldRetryConfigWrite(err)) {
const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {});
const snapshot = await callGateway<GatewayConfigSnapshot>(params.client, "config.get", {});
return applyGatewayConfigPatch({
...params,
baseHash: snapshot.hash ?? undefined,
@@ -355,7 +369,10 @@ export const renameGatewayAgent = async (params: {
if (!trimmed) {
throw new Error("Agent name is required.");
}
await params.client.call("agents.update", { agentId: params.agentId, name: trimmed });
await callGateway(params.client, "agents.update", {
agentId: params.agentId,
name: trimmed,
});
return { id: params.agentId, name: trimmed };
};
@@ -382,7 +399,7 @@ export const createGatewayAgent = async (params: {
throw new Error("Agent name is required.");
}
const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {});
const snapshot = await callGateway<GatewayConfigSnapshot>(params.client, "config.get", {});
const configPath = typeof snapshot.path === "string" ? snapshot.path.trim() : "";
if (!configPath) {
throw new Error(
@@ -398,10 +415,14 @@ export const createGatewayAgent = async (params: {
const idGuess = slugifyAgentName(trimmed);
const workspace = joinPathLike(stateDir, `workspace-${idGuess}`);
const result = (await params.client.call("agents.create", {
const result = await callGateway<{ ok?: boolean; agentId?: string; name?: string; workspace?: string }>(
params.client,
"agents.create",
{
name: trimmed,
workspace,
})) as { ok?: boolean; agentId?: string; name?: string; workspace?: string };
}
);
const agentId = typeof result?.agentId === "string" ? result.agentId.trim() : "";
if (!agentId) {
throw new Error("Gateway returned an invalid agents.create response (missing agentId).");
@@ -414,9 +435,9 @@ export const deleteGatewayAgent = async (params: {
agentId: string;
}) => {
try {
const result = (await params.client.call("agents.delete", {
const result = await callGateway<{ ok?: boolean; removedBindings?: unknown }>(params.client, "agents.delete", {
agentId: params.agentId,
})) as { ok?: boolean; removedBindings?: unknown };
});
const removedBindings =
typeof result?.removedBindings === "number" && Number.isFinite(result.removedBindings)
? Math.max(0, Math.floor(result.removedBindings))
@@ -435,7 +456,7 @@ export const updateGatewayHeartbeat = async (params: {
agentId: string;
payload: AgentHeartbeatUpdatePayload;
}): Promise<AgentHeartbeatResult> => {
const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {});
const snapshot = await callGateway<GatewayConfigSnapshot>(params.client, "config.get", {});
const baseConfig = isRecord(snapshot.config) ? snapshot.config : {};
const list = readConfigAgentList(baseConfig);
const { list: nextList } = upsertConfigAgentEntry(list, params.agentId, (entry) => {
@@ -461,7 +482,7 @@ export const removeGatewayHeartbeatOverride = async (params: {
client: GatewayClient;
agentId: string;
}): Promise<AgentHeartbeatResult> => {
const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {});
const snapshot = await callGateway<GatewayConfigSnapshot>(params.client, "config.get", {});
const baseConfig = isRecord(snapshot.config) ? snapshot.config : {};
const list = readConfigAgentList(baseConfig);
const nextList = list.map((entry) => {
@@ -580,7 +601,7 @@ export const readGatewayAgentSkillsAllowlist = async (params: {
agentId: string;
}): Promise<string[] | undefined> => {
const agentId = resolveRequiredAgentId(params.agentId);
const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {});
const snapshot = await callGateway<GatewayConfigSnapshot>(params.client, "config.get", {});
const baseConfig = isRecord(snapshot.config) ? snapshot.config : {};
const list = readConfigAgentList(baseConfig);
const entry = list.find((item) => item.id === agentId);
@@ -606,7 +627,7 @@ export const updateGatewayAgentSkillsAllowlist = async (params: {
}
const attemptWrite = async (attempt: number): Promise<void> => {
const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {});
const snapshot = await callGateway<GatewayConfigSnapshot>(params.client, "config.get", {});
const baseConfig = isRecord(snapshot.config) ? snapshot.config : {};
const nextConfig = buildAgentSkillsConfig({
baseConfig,
@@ -629,7 +650,7 @@ export const updateGatewayAgentSkillsAllowlist = async (params: {
payload.baseHash = baseHash;
}
try {
await params.client.call("config.set", payload);
await callGateway(params.client, "config.set", payload);
} catch (err) {
if (attempt < 1 && shouldRetryConfigWrite(err)) {
return attemptWrite(attempt + 1);
@@ -735,7 +756,7 @@ export const updateGatewayAgentOverrides = async (params: {
};
const attemptWrite = async (attempt: number): Promise<void> => {
const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {});
const snapshot = await callGateway<GatewayConfigSnapshot>(params.client, "config.get", {});
const baseConfig = isRecord(snapshot.config) ? snapshot.config : {};
const nextConfig = buildNextConfig(baseConfig);
const payload: Record<string, unknown> = {
@@ -748,7 +769,7 @@ export const updateGatewayAgentOverrides = async (params: {
}
if (baseHash) payload.baseHash = baseHash;
try {
await params.client.call("config.set", payload);
await callGateway(params.client, "config.set", payload);
} catch (err) {
if (attempt < 1 && shouldRetryConfigWrite(err)) {
return attemptWrite(attempt + 1);
+16 -16
View File
@@ -5,6 +5,20 @@ type AgentsFilesGetResponse = {
file?: { missing?: unknown; content?: unknown };
};
const callGateway = async <T>(
client: GatewayClient,
method: string,
params: unknown
): Promise<T> => {
const invoke = (
client as unknown as { call?: (nextMethod: string, nextParams: unknown) => Promise<unknown> }
).call;
if (typeof invoke !== "function") {
throw new Error("Legacy gateway client call transport is unavailable.");
}
return (await invoke(method, params)) as T;
};
const resolveAgentId = (value: string) => {
const trimmed = value.trim();
if (!trimmed) {
@@ -19,7 +33,7 @@ export const readGatewayAgentFile = async (params: {
name: AgentFileName;
}): Promise<{ exists: boolean; content: string }> => {
const agentId = resolveAgentId(params.agentId);
const response = await params.client.call<AgentsFilesGetResponse>("agents.files.get", {
const response = await callGateway<AgentsFilesGetResponse>(params.client, "agents.files.get", {
agentId,
name: params.name,
});
@@ -31,20 +45,6 @@ export const readGatewayAgentFile = async (params: {
return { exists: !missing, content };
};
export const writeGatewayAgentFile = async (params: {
client: GatewayClient;
agentId: string;
name: AgentFileName;
content: string;
}): Promise<void> => {
const agentId = resolveAgentId(params.agentId);
await params.client.call("agents.files.set", {
agentId,
name: params.name,
content: params.content,
});
};
export const writeGatewayAgentFiles = async (params: {
client: GatewayClient;
agentId: string;
@@ -55,7 +55,7 @@ export const writeGatewayAgentFiles = async (params: {
(entry): entry is [AgentFileName, string] => typeof entry[1] === "string"
);
for (const [name, content] of entries) {
await params.client.call("agents.files.set", {
await callGateway(params.client, "agents.files.set", {
agentId,
name,
content,
+30 -4
View File
@@ -41,6 +41,20 @@ type ExecApprovalsSnapshot = {
file?: ExecApprovalsFile;
};
const callGateway = async <T>(
client: GatewayClient,
method: string,
params: unknown
): Promise<T> => {
const invoke = (
client as unknown as { call?: (nextMethod: string, nextParams: unknown) => Promise<unknown> }
).call;
if (typeof invoke !== "function") {
throw new Error("Legacy gateway client call transport is unavailable.");
}
return (await invoke(method, params)) as T;
};
const shouldRetrySet = (err: unknown): boolean => {
if (!(err instanceof GatewayResponseError)) return false;
return /re-run exec\.approvals\.get|changed since last load/i.test(err.message);
@@ -69,10 +83,14 @@ const setExecApprovalsWithRetry = async (params: {
const payload: Record<string, unknown> = { file: params.file };
if (baseHash) payload.baseHash = baseHash;
try {
await params.client.call("exec.approvals.set", payload);
await callGateway(params.client, "exec.approvals.set", payload);
} catch (err) {
if (attempt < 1 && shouldRetrySet(err)) {
const snapshot = await params.client.call<ExecApprovalsSnapshot>("exec.approvals.get", {});
const snapshot = await callGateway<ExecApprovalsSnapshot>(
params.client,
"exec.approvals.get",
{}
);
return setExecApprovalsWithRetry({
...params,
baseHash: snapshot.hash ?? undefined,
@@ -98,7 +116,11 @@ export async function upsertGatewayAgentExecApprovals(params: {
throw new Error("Agent id is required.");
}
const snapshot = await params.client.call<ExecApprovalsSnapshot>("exec.approvals.get", {});
const snapshot = await callGateway<ExecApprovalsSnapshot>(
params.client,
"exec.approvals.get",
{}
);
const baseFile: ExecApprovalsFile =
snapshot.file && typeof snapshot.file === "object"
? {
@@ -152,7 +174,11 @@ export async function readGatewayAgentExecApprovals(params: {
throw new Error("Agent id is required.");
}
const snapshot = await params.client.call<ExecApprovalsSnapshot>("exec.approvals.get", {});
const snapshot = await callGateway<ExecApprovalsSnapshot>(
params.client,
"exec.approvals.get",
{}
);
const entry = snapshot.file?.agents?.[agentId];
if (!entry) return null;
+33
View File
@@ -0,0 +1,33 @@
import { GatewayResponseError } from "@/lib/gateway/errors";
export const isGatewayDisconnectLikeError = (err: unknown): boolean => {
if (!(err instanceof Error)) return false;
const msg = err.message.toLowerCase();
if (!msg) return false;
if (
msg.includes("gateway not connected") ||
msg.includes("gateway is not connected") ||
msg.includes("gateway client stopped")
) {
return true;
}
const match = msg.match(/gateway closed \\((\\d+)\\)/);
if (!match) return false;
const code = Number(match[1]);
return Number.isFinite(code) && code === 1012;
};
const WEBCHAT_SESSION_MUTATION_BLOCKED_RE = /webchat clients cannot (patch|delete) sessions/i;
const WEBCHAT_SESSION_MUTATION_HINT_RE = /use chat\.send for session-scoped updates/i;
export const isWebchatSessionMutationBlockedError = (error: unknown): boolean => {
if (!(error instanceof GatewayResponseError)) return false;
if (error.code.trim().toUpperCase() !== "INVALID_REQUEST") return false;
const message = error.message.trim();
if (!message) return false;
return (
WEBCHAT_SESSION_MUTATION_BLOCKED_RE.test(message) &&
WEBCHAT_SESSION_MUTATION_HINT_RE.test(message)
);
};
+43
View File
@@ -0,0 +1,43 @@
type GatewayStateVersion = {
presence: number;
health: number;
};
type ReqFrame = {
type: "req";
id: string;
method: string;
params: unknown;
};
type ResFrame = {
type: "res";
id: string;
ok: boolean;
payload?: unknown;
error?: {
code: string;
message: string;
details?: unknown;
retryable?: boolean;
retryAfterMs?: number;
};
};
export type EventFrame = {
type: "event";
event: string;
payload?: unknown;
seq?: number;
stateVersion?: GatewayStateVersion;
};
type GatewayFrame = ReqFrame | ResFrame | EventFrame;
export const parseGatewayFrame = (raw: string): GatewayFrame | null => {
try {
return JSON.parse(raw) as GatewayFrame;
} catch {
return null;
}
};
+6
View File
@@ -0,0 +1,6 @@
export type GatewayStatus = "disconnected" | "connecting" | "connected";
export type GatewayGapInfo = {
expected: number;
received: number;
};
+17 -3
View File
@@ -10,6 +10,20 @@ type GatewayConfigSnapshot = {
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object" && !Array.isArray(value));
const callGateway = async <T>(
client: GatewayClient,
method: string,
params: unknown
): Promise<T> => {
const invoke = (
client as unknown as { call?: (nextMethod: string, nextParams: unknown) => Promise<unknown> }
).call;
if (typeof invoke !== "function") {
throw new Error("Legacy gateway client call transport is unavailable.");
}
return (await invoke(method, params)) as T;
};
const shouldRetryConfigWrite = (err: unknown) => {
if (!(err instanceof GatewayResponseError)) return false;
return /re-run config\.get|config changed since last load/i.test(err.message);
@@ -37,7 +51,7 @@ export async function shouldAwaitDisconnectRestartForRemoteMutation(params: {
return shouldAwaitDisconnectRestartForReloadMode(cachedMode);
}
try {
const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {});
const snapshot = await callGateway<GatewayConfigSnapshot>(params.client, "config.get", {});
const mode = resolveReloadModeFromConfig(snapshot.config);
return shouldAwaitDisconnectRestartForReloadMode(mode);
} catch (err) {
@@ -58,7 +72,7 @@ export async function ensureGatewayReloadModeHotForLocalStudio(params: {
}
const attemptWrite = async (attempt: number): Promise<void> => {
const snapshot = await params.client.call<GatewayConfigSnapshot>("config.get", {});
const snapshot = await callGateway<GatewayConfigSnapshot>(params.client, "config.get", {});
const exists = snapshot.exists !== false;
const baseHash = exists ? snapshot.hash?.trim() : undefined;
if (exists && !baseHash) {
@@ -93,7 +107,7 @@ export async function ensureGatewayReloadModeHotForLocalStudio(params: {
}
try {
await params.client.call("config.set", payload);
await callGateway(params.client, "config.set", payload);
} catch (err) {
if (attempt < 1 && shouldRetryConfigWrite(err)) {
await attemptWrite(attempt + 1);
@@ -1,643 +0,0 @@
import { getPublicKeyAsync, signAsync, utils } from "@noble/ed25519";
import { GatewayResponseError } from "@/lib/gateway/errors";
const GATEWAY_CLIENT_NAMES = {
CONTROL_UI: "openclaw-control-ui",
} as const;
const GATEWAY_CLIENT_MODES = {
WEBCHAT: "webchat",
} as const;
type CryptoLike = {
randomUUID?: (() => string) | undefined;
getRandomValues?: ((array: Uint8Array) => Uint8Array) | undefined;
};
let warnedWeakCrypto = false;
function uuidFromBytes(bytes: Uint8Array): string {
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1
let hex = "";
for (let i = 0; i < bytes.length; i++) {
hex += bytes[i]!.toString(16).padStart(2, "0");
}
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(
16,
20
)}-${hex.slice(20)}`;
}
function weakRandomBytes(): Uint8Array {
const bytes = new Uint8Array(16);
const now = Date.now();
for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
bytes[0] ^= now & 0xff;
bytes[1] ^= (now >>> 8) & 0xff;
bytes[2] ^= (now >>> 16) & 0xff;
bytes[3] ^= (now >>> 24) & 0xff;
return bytes;
}
function warnWeakCryptoOnce() {
if (warnedWeakCrypto) return;
warnedWeakCrypto = true;
console.warn("[uuid] crypto API missing; falling back to weak randomness");
}
function generateUUID(cryptoLike: CryptoLike | null = globalThis.crypto): string {
if (cryptoLike && typeof cryptoLike.randomUUID === "function") return cryptoLike.randomUUID();
if (cryptoLike && typeof cryptoLike.getRandomValues === "function") {
const bytes = new Uint8Array(16);
cryptoLike.getRandomValues(bytes);
return uuidFromBytes(bytes);
}
warnWeakCryptoOnce();
return uuidFromBytes(weakRandomBytes());
}
type DeviceAuthPayloadParams = {
deviceId: string;
clientId: string;
clientMode: string;
role: string;
scopes: string[];
signedAtMs: number;
token?: string | null;
nonce?: string | null;
version?: "v1" | "v2";
};
function buildDeviceAuthPayload(params: DeviceAuthPayloadParams): string {
const version = params.version ?? (params.nonce ? "v2" : "v1");
const scopes = params.scopes.join(",");
const token = params.token ?? "";
const base = [
version,
params.deviceId,
params.clientId,
params.clientMode,
params.role,
scopes,
String(params.signedAtMs),
token,
];
if (version === "v2") {
base.push(params.nonce ?? "");
}
return base.join("|");
}
type DeviceAuthEntry = {
token: string;
role: string;
scopes: string[];
updatedAtMs: number;
};
type DeviceAuthStore = {
version: 1;
deviceId: string;
tokens: Record<string, DeviceAuthEntry>;
};
const DEVICE_AUTH_STORAGE_KEY = "openclaw.device.auth.v1";
function normalizeAuthScope(scope: string | undefined): string {
const trimmed = scope?.trim();
if (!trimmed) return "default";
return trimmed.toLowerCase();
}
function buildScopedTokenKey(scope: string, role: string): string {
return `${scope}::${role}`;
}
function normalizeRole(role: string): string {
return role.trim();
}
function normalizeScopes(scopes: string[] | undefined): string[] {
if (!Array.isArray(scopes)) return [];
const out = new Set<string>();
for (const scope of scopes) {
const trimmed = scope.trim();
if (trimmed) out.add(trimmed);
}
return [...out].sort();
}
function readDeviceAuthStore(): DeviceAuthStore | null {
try {
const raw = window.localStorage.getItem(DEVICE_AUTH_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as DeviceAuthStore;
if (!parsed || parsed.version !== 1) return null;
if (!parsed.deviceId || typeof parsed.deviceId !== "string") return null;
if (!parsed.tokens || typeof parsed.tokens !== "object") return null;
return parsed;
} catch {
return null;
}
}
function writeDeviceAuthStore(store: DeviceAuthStore) {
try {
window.localStorage.setItem(DEVICE_AUTH_STORAGE_KEY, JSON.stringify(store));
} catch {
// best-effort
}
}
function loadDeviceAuthToken(params: { deviceId: string; role: string; scope: string }): DeviceAuthEntry | null {
const store = readDeviceAuthStore();
if (!store || store.deviceId !== params.deviceId) return null;
const role = normalizeRole(params.role);
const scope = normalizeAuthScope(params.scope);
const key = buildScopedTokenKey(scope, role);
const entry = store.tokens[key];
if (!entry || typeof entry.token !== "string") return null;
return entry;
}
function storeDeviceAuthToken(params: {
deviceId: string;
role: string;
scope: string;
token: string;
scopes?: string[];
}): DeviceAuthEntry {
const role = normalizeRole(params.role);
const scope = normalizeAuthScope(params.scope);
const key = buildScopedTokenKey(scope, role);
const next: DeviceAuthStore = {
version: 1,
deviceId: params.deviceId,
tokens: {},
};
const existing = readDeviceAuthStore();
if (existing && existing.deviceId === params.deviceId) {
next.tokens = { ...existing.tokens };
}
const entry: DeviceAuthEntry = {
token: params.token,
role,
scopes: normalizeScopes(params.scopes),
updatedAtMs: Date.now(),
};
next.tokens[key] = entry;
writeDeviceAuthStore(next);
return entry;
}
function clearDeviceAuthToken(params: { deviceId: string; role: string; scope: string }) {
const store = readDeviceAuthStore();
if (!store || store.deviceId !== params.deviceId) return;
const role = normalizeRole(params.role);
const scope = normalizeAuthScope(params.scope);
const key = buildScopedTokenKey(scope, role);
const hasScoped = Boolean(store.tokens[key]);
const hasLegacy = Boolean(store.tokens[role]);
if (!hasScoped && !hasLegacy) return;
const next = { ...store, tokens: { ...store.tokens } };
delete next.tokens[key];
delete next.tokens[role];
writeDeviceAuthStore(next);
}
type StoredIdentity = {
version: 1;
deviceId: string;
publicKey: string;
privateKey: string;
createdAtMs: number;
};
type DeviceIdentity = {
deviceId: string;
publicKey: string;
privateKey: string;
};
const DEVICE_IDENTITY_STORAGE_KEY = "openclaw-device-identity-v1";
function base64UrlEncode(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, "");
}
function base64UrlDecode(input: string): Uint8Array {
const normalized = input.replaceAll("-", "+").replaceAll("_", "/");
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
const binary = atob(padded);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);
return out;
}
function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
async function fingerprintPublicKey(publicKey: Uint8Array): Promise<string> {
const hash = await crypto.subtle.digest("SHA-256", new Uint8Array(publicKey));
return bytesToHex(new Uint8Array(hash));
}
async function generateIdentity(): Promise<DeviceIdentity> {
const privateKey = utils.randomSecretKey();
const publicKey = await getPublicKeyAsync(privateKey);
const deviceId = await fingerprintPublicKey(publicKey);
return {
deviceId,
publicKey: base64UrlEncode(publicKey),
privateKey: base64UrlEncode(privateKey),
};
}
async function loadOrCreateDeviceIdentity(): Promise<DeviceIdentity> {
try {
const raw = localStorage.getItem(DEVICE_IDENTITY_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw) as StoredIdentity;
if (
parsed?.version === 1 &&
typeof parsed.deviceId === "string" &&
typeof parsed.publicKey === "string" &&
typeof parsed.privateKey === "string"
) {
const derivedId = await fingerprintPublicKey(base64UrlDecode(parsed.publicKey));
if (derivedId !== parsed.deviceId) {
const updated: StoredIdentity = {
...parsed,
deviceId: derivedId,
};
localStorage.setItem(DEVICE_IDENTITY_STORAGE_KEY, JSON.stringify(updated));
return {
deviceId: derivedId,
publicKey: parsed.publicKey,
privateKey: parsed.privateKey,
};
}
return {
deviceId: parsed.deviceId,
publicKey: parsed.publicKey,
privateKey: parsed.privateKey,
};
}
}
} catch {
// fall through to regenerate
}
const identity = await generateIdentity();
const stored: StoredIdentity = {
version: 1,
deviceId: identity.deviceId,
publicKey: identity.publicKey,
privateKey: identity.privateKey,
createdAtMs: Date.now(),
};
localStorage.setItem(DEVICE_IDENTITY_STORAGE_KEY, JSON.stringify(stored));
return identity;
}
async function signDevicePayload(privateKeyBase64Url: string, payload: string) {
const key = base64UrlDecode(privateKeyBase64Url);
const data = new TextEncoder().encode(payload);
const sig = await signAsync(data, key);
return base64UrlEncode(sig);
}
type GatewayEventFrame = {
type: "event";
event: string;
payload?: unknown;
seq?: number;
stateVersion?: { presence: number; health: number };
};
type GatewayResponseFrame = {
type: "res";
id: string;
ok: boolean;
payload?: unknown;
error?: { code: string; message: string; details?: unknown };
};
export type GatewayHelloOk = {
type: "hello-ok";
protocol: number;
features?: { methods?: string[]; events?: string[] };
snapshot?: unknown;
auth?: {
deviceToken?: string;
role?: string;
scopes?: string[];
issuedAtMs?: number;
};
policy?: { tickIntervalMs?: number };
};
type Pending = {
resolve: (value: unknown) => void;
reject: (err: unknown) => void;
};
type GatewayBrowserClientOptions = {
url: string;
token?: string;
password?: string;
authScopeKey?: string;
disableDeviceAuth?: boolean;
clientName?: string;
clientVersion?: string;
platform?: string;
mode?: string;
instanceId?: string;
onHello?: (hello: GatewayHelloOk) => void;
onEvent?: (evt: GatewayEventFrame) => void;
onClose?: (info: { code: number; reason: string }) => void;
onGap?: (info: { expected: number; received: number }) => void;
};
const CONNECT_FAILED_CLOSE_CODE = 4008;
const WS_CLOSE_REASON_MAX_BYTES = 123;
function truncateWsCloseReason(reason: string, maxBytes = WS_CLOSE_REASON_MAX_BYTES): string {
const trimmed = reason.trim();
if (!trimmed) return "connect failed";
const encoder = new TextEncoder();
if (encoder.encode(trimmed).byteLength <= maxBytes) return trimmed;
let out = "";
for (const char of trimmed) {
const next = out + char;
if (encoder.encode(next).byteLength > maxBytes) break;
out = next;
}
return out.trimEnd() || "connect failed";
}
export class GatewayBrowserClient {
private ws: WebSocket | null = null;
private pending = new Map<string, Pending>();
private closed = false;
private lastSeq: number | null = null;
private connectNonce: string | null = null;
private connectSent = false;
private connectTimer: number | null = null;
private backoffMs = 800;
constructor(private opts: GatewayBrowserClientOptions) {}
start() {
this.closed = false;
this.connect();
}
stop() {
this.closed = true;
this.ws?.close();
this.ws = null;
this.flushPending(new Error("gateway client stopped"));
}
get connected() {
return this.ws?.readyState === WebSocket.OPEN;
}
private connect() {
if (this.closed) return;
this.ws = new WebSocket(this.opts.url);
this.ws.onopen = () => this.queueConnect();
this.ws.onmessage = (ev) => this.handleMessage(String(ev.data ?? ""));
this.ws.onclose = (ev) => {
const reason = String(ev.reason ?? "");
this.ws = null;
this.flushPending(new Error(`gateway closed (${ev.code}): ${reason}`));
this.opts.onClose?.({ code: ev.code, reason });
this.scheduleReconnect();
};
this.ws.onerror = () => {
// ignored; close handler will fire
};
}
private scheduleReconnect() {
if (this.closed) return;
const delay = this.backoffMs;
this.backoffMs = Math.min(this.backoffMs * 1.7, 15_000);
window.setTimeout(() => this.connect(), delay);
}
private flushPending(err: Error) {
for (const [, p] of this.pending) p.reject(err);
this.pending.clear();
}
private async sendConnect() {
if (this.connectSent) return;
this.connectSent = true;
if (this.connectTimer !== null) {
window.clearTimeout(this.connectTimer);
this.connectTimer = null;
}
const isSecureContext =
!this.opts.disableDeviceAuth && typeof crypto !== "undefined" && !!crypto.subtle;
const scopes = ["operator.admin", "operator.approvals", "operator.pairing"];
const role = "operator";
const authScopeKey = normalizeAuthScope(this.opts.authScopeKey ?? this.opts.url);
let deviceIdentity: Awaited<ReturnType<typeof loadOrCreateDeviceIdentity>> | null = null;
let canFallbackToShared = false;
let authToken = this.opts.token;
if (isSecureContext) {
deviceIdentity = await loadOrCreateDeviceIdentity();
const storedToken = loadDeviceAuthToken({
deviceId: deviceIdentity.deviceId,
role,
scope: authScopeKey,
})?.token;
authToken = storedToken ?? this.opts.token;
canFallbackToShared = Boolean(storedToken && this.opts.token);
}
const auth =
authToken || this.opts.password
? {
token: authToken,
password: this.opts.password,
}
: undefined;
let device:
| {
id: string;
publicKey: string;
signature: string;
signedAt: number;
nonce: string | undefined;
}
| undefined;
if (isSecureContext && deviceIdentity) {
const signedAtMs = Date.now();
const nonce = this.connectNonce ?? undefined;
const payload = buildDeviceAuthPayload({
deviceId: deviceIdentity.deviceId,
clientId: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI,
clientMode: this.opts.mode ?? GATEWAY_CLIENT_MODES.WEBCHAT,
role,
scopes,
signedAtMs,
token: authToken ?? null,
nonce,
});
const signature = await signDevicePayload(deviceIdentity.privateKey, payload);
device = {
id: deviceIdentity.deviceId,
publicKey: deviceIdentity.publicKey,
signature,
signedAt: signedAtMs,
nonce,
};
}
const params = {
minProtocol: 3,
maxProtocol: 3,
client: {
id: this.opts.clientName ?? GATEWAY_CLIENT_NAMES.CONTROL_UI,
version: this.opts.clientVersion ?? "dev",
platform: this.opts.platform ?? navigator.platform ?? "web",
mode: this.opts.mode ?? GATEWAY_CLIENT_MODES.WEBCHAT,
instanceId: this.opts.instanceId,
},
role,
scopes,
device,
caps: [],
auth,
userAgent: navigator.userAgent,
locale: navigator.language,
};
void this.request<GatewayHelloOk>("connect", params)
.then((hello) => {
if (hello?.auth?.deviceToken && deviceIdentity) {
storeDeviceAuthToken({
deviceId: deviceIdentity.deviceId,
role: hello.auth.role ?? role,
scope: authScopeKey,
token: hello.auth.deviceToken,
scopes: hello.auth.scopes ?? [],
});
}
this.backoffMs = 800;
this.opts.onHello?.(hello);
})
.catch((err) => {
if (canFallbackToShared && deviceIdentity) {
clearDeviceAuthToken({ deviceId: deviceIdentity.deviceId, role, scope: authScopeKey });
}
const rawReason =
err instanceof GatewayResponseError
? `connect failed: ${err.code} ${err.message}`
: "connect failed";
const reason = truncateWsCloseReason(rawReason);
if (reason !== rawReason) {
console.warn("[gateway] connect close reason truncated to 123 UTF-8 bytes");
}
this.ws?.close(CONNECT_FAILED_CLOSE_CODE, reason);
});
}
private handleMessage(raw: string) {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return;
}
const frame = parsed as { type?: unknown };
if (frame.type === "event") {
const evt = parsed as GatewayEventFrame;
if (evt.event === "connect.challenge") {
const payload = evt.payload as { nonce?: unknown } | undefined;
const nonce = payload && typeof payload.nonce === "string" ? payload.nonce : null;
if (nonce) {
this.connectNonce = nonce;
void this.sendConnect();
}
return;
}
const seq = typeof evt.seq === "number" ? evt.seq : null;
if (seq !== null) {
if (this.lastSeq !== null && seq > this.lastSeq + 1) {
this.opts.onGap?.({ expected: this.lastSeq + 1, received: seq });
}
this.lastSeq = seq;
}
try {
this.opts.onEvent?.(evt);
} catch (err) {
console.error("[gateway] event handler error:", err);
}
return;
}
if (frame.type === "res") {
const res = parsed as GatewayResponseFrame;
const pending = this.pending.get(res.id);
if (!pending) return;
this.pending.delete(res.id);
if (res.ok) pending.resolve(res.payload);
else {
if (res.error && typeof res.error.code === "string") {
pending.reject(
new GatewayResponseError({
code: res.error.code,
message: res.error.message ?? "request failed",
details: res.error.details,
})
);
return;
}
pending.reject(new Error(res.error?.message ?? "request failed"));
}
return;
}
}
request<T = unknown>(method: string, params?: unknown): Promise<T> {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
return Promise.reject(new Error("gateway not connected"));
}
const id = generateUUID();
const frame = { type: "req", id, method, params };
const p = new Promise<T>((resolve, reject) => {
this.pending.set(id, { resolve: (v) => resolve(v as T), reject });
});
this.ws.send(JSON.stringify(frame));
return p;
}
private queueConnect() {
this.connectNonce = null;
this.connectSent = false;
if (this.connectTimer !== null) window.clearTimeout(this.connectTimer);
this.connectTimer = window.setTimeout(() => {
void this.sendConnect();
}, 750);
}
}
-6
View File
@@ -1,6 +0,0 @@
export const resolveStudioProxyGatewayUrl = (): string => {
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
const host = window.location.host;
return `${protocol}://${host}/api/gateway/ws`;
};
+80
View File
@@ -0,0 +1,80 @@
type SessionSettingsPatchPayload = {
key: string;
model?: string | null;
thinkingLevel?: string | null;
execHost?: "sandbox" | "gateway" | "node" | null;
execSecurity?: "deny" | "allowlist" | "full" | null;
execAsk?: "off" | "on-miss" | "always" | null;
};
export type GatewaySessionsPatchResult = {
ok: true;
key: string;
entry?: {
thinkingLevel?: string;
};
resolved?: {
modelProvider?: string;
model?: string;
};
};
type GatewaySessionSettingsSyncClient = {
call: <T = unknown>(method: string, params: unknown) => Promise<T>;
};
type SyncGatewaySessionSettingsParams = {
client: GatewaySessionSettingsSyncClient;
sessionKey: string;
model?: string | null;
thinkingLevel?: string | null;
execHost?: "sandbox" | "gateway" | "node" | null;
execSecurity?: "deny" | "allowlist" | "full" | null;
execAsk?: "off" | "on-miss" | "always" | null;
};
export const syncGatewaySessionSettings = async ({
client,
sessionKey,
model,
thinkingLevel,
execHost,
execSecurity,
execAsk,
}: SyncGatewaySessionSettingsParams) => {
const key = sessionKey.trim();
if (!key) {
throw new Error("Session key is required.");
}
const includeModel = model !== undefined;
const includeThinkingLevel = thinkingLevel !== undefined;
const includeExecHost = execHost !== undefined;
const includeExecSecurity = execSecurity !== undefined;
const includeExecAsk = execAsk !== undefined;
if (
!includeModel &&
!includeThinkingLevel &&
!includeExecHost &&
!includeExecSecurity &&
!includeExecAsk
) {
throw new Error("At least one session setting must be provided.");
}
const payload: SessionSettingsPatchPayload = { key };
if (includeModel) {
payload.model = model ?? null;
}
if (includeThinkingLevel) {
payload.thinkingLevel = thinkingLevel ?? null;
}
if (includeExecHost) {
payload.execHost = execHost ?? null;
}
if (includeExecSecurity) {
payload.execSecurity = execSecurity ?? null;
}
if (includeExecAsk) {
payload.execAsk = execAsk ?? null;
}
return await client.call<GatewaySessionsPatchResult>("sessions.patch", payload);
};
+1 -1
View File
@@ -20,7 +20,7 @@ export const removeSkillFromGateway = async (
managedSkillsDir: normalizeRequired(request.managedSkillsDir, "managedSkillsDir"),
};
const response = await fetchJson<{ result: SkillRemoveResult }>("/api/gateway/skills/remove", {
const response = await fetchJson<{ result: SkillRemoveResult }>("/api/intents/skills-remove", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
+2 -2
View File
@@ -1,11 +1,11 @@
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
export type SkillStatusConfigCheck = {
type SkillStatusConfigCheck = {
path: string;
satisfied: boolean;
};
export type SkillRequirementSet = {
type SkillRequirementSet = {
bins: string[];
anyBins: string[];
env: string[];
+3 -3
View File
@@ -3,13 +3,13 @@ export type StudioGatewaySettings = {
token: string;
};
export type StudioGatewaySettingsPatch = {
type StudioGatewaySettingsPatch = {
url?: string | null;
token?: string | null;
};
export type FocusFilter = "all" | "running" | "approvals";
export type StudioViewMode = "focused";
type FocusFilter = "all" | "running" | "approvals";
type StudioViewMode = "focused";
export type StudioFocusedPreference = {
mode: StudioViewMode;
+243
View File
@@ -0,0 +1,243 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
import type { GatewayStatus } from "@/lib/gateway/gateway-status";
import { fetchJson } from "@/lib/http";
import type {
StudioGatewaySettings,
StudioSettings,
StudioSettingsPatch,
} from "@/lib/studio/settings";
import type { StudioSettingsResponse } from "@/lib/studio/coordinator";
const DEFAULT_UPSTREAM_GATEWAY_URL =
process.env.NEXT_PUBLIC_GATEWAY_URL || "ws://localhost:18789";
const removedGatewayClient: GatewayClient = {
call: async () => {
throw new Error("Browser gateway transport has been removed. Use Studio domain APIs.");
},
onEvent: () => () => {},
onGap: () => () => {},
};
const normalizeLocalGatewayDefaults = (value: unknown): StudioGatewaySettings | null => {
if (!value || typeof value !== "object") return null;
const raw = value as { url?: unknown; token?: unknown };
const url = typeof raw.url === "string" ? raw.url.trim() : "";
const token = typeof raw.token === "string" ? raw.token.trim() : "";
if (!url) return null;
return { url, token };
};
const formatGatewayError = (error: unknown): string => {
if (error instanceof Error) return error.message;
return "Unknown gateway error.";
};
type RuntimeSummaryEnvelope = {
summary?: {
status?: unknown;
} | null;
error?: unknown;
};
const mapRuntimeStatusToGatewayStatus = (value: unknown): GatewayStatus => {
const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
if (normalized === "connected") return "connected";
if (normalized === "connecting" || normalized === "reconnecting") return "connecting";
return "disconnected";
};
type StudioSettingsCoordinatorLike = {
loadSettings: () => Promise<StudioSettings | null>;
loadSettingsEnvelope?: () => Promise<StudioSettingsResponse>;
schedulePatch: (patch: StudioSettingsPatch, debounceMs?: number) => void;
flushPending: () => Promise<void>;
};
type StudioGatewaySettingsState = {
client: GatewayClient;
status: GatewayStatus;
gatewayUrl: string;
token: string;
localGatewayDefaults: StudioGatewaySettings | null;
domainApiModeEnabled: boolean;
error: string | null;
connect: () => Promise<void>;
disconnect: () => void;
useLocalGatewayDefaults: () => void;
setGatewayUrl: (value: string) => void;
setToken: (value: string) => void;
clearError: () => void;
};
export const useStudioGatewaySettings = (
settingsCoordinator: StudioSettingsCoordinatorLike
): StudioGatewaySettingsState => {
const [gatewayUrl, setGatewayUrlState] = useState(DEFAULT_UPSTREAM_GATEWAY_URL);
const [token, setTokenState] = useState("");
const [localGatewayDefaults, setLocalGatewayDefaults] = useState<StudioGatewaySettings | null>(
null
);
const domainApiModeEnabled = true;
const [status, setStatus] = useState<GatewayStatus>("disconnected");
const [error, setError] = useState<string | null>(null);
const [settingsLoaded, setSettingsLoaded] = useState(false);
const manualDisconnectRef = useRef(false);
const didAutoConnectRef = useRef(false);
const clearError = useCallback(() => {
setError(null);
}, []);
useEffect(() => {
let cancelled = false;
const loadSettings = async () => {
try {
const envelope =
typeof settingsCoordinator.loadSettingsEnvelope === "function"
? await settingsCoordinator.loadSettingsEnvelope()
: { settings: await settingsCoordinator.loadSettings(), localGatewayDefaults: null };
const settings = envelope.settings ?? null;
const gateway = settings?.gateway ?? null;
if (cancelled) return;
const nextUrl = gateway?.url?.trim() ? gateway.url : DEFAULT_UPSTREAM_GATEWAY_URL;
const nextToken = typeof gateway?.token === "string" ? gateway.token : "";
setGatewayUrlState(nextUrl);
setTokenState(nextToken);
setLocalGatewayDefaults(normalizeLocalGatewayDefaults(envelope.localGatewayDefaults));
} catch (nextError) {
if (!cancelled) {
setError(formatGatewayError(nextError));
}
} finally {
if (!cancelled) {
setSettingsLoaded(true);
}
}
};
void loadSettings();
return () => {
cancelled = true;
};
}, [settingsCoordinator]);
const connect = useCallback(async () => {
const trimmedGatewayUrl = gatewayUrl.trim();
if (!trimmedGatewayUrl) {
setStatus("disconnected");
setError("Gateway URL is required.");
return;
}
setStatus("connecting");
setError(null);
manualDisconnectRef.current = false;
try {
await settingsCoordinator.flushPending();
const summary = await fetchJson<RuntimeSummaryEnvelope>("/api/runtime/summary", {
cache: "no-store",
});
const nextStatus = mapRuntimeStatusToGatewayStatus(summary?.summary?.status);
setStatus(nextStatus);
const runtimeError =
typeof summary?.error === "string" ? summary.error.trim() : "";
if (nextStatus === "connected" || !runtimeError) {
setError(null);
} else {
setError(runtimeError);
}
} catch (nextError) {
setStatus("disconnected");
setError(formatGatewayError(nextError));
}
}, [gatewayUrl, settingsCoordinator]);
const disconnect = useCallback(() => {
manualDisconnectRef.current = true;
setStatus("disconnected");
}, []);
useEffect(() => {
if (!settingsLoaded) return;
if (manualDisconnectRef.current) return;
if (didAutoConnectRef.current) return;
if (status !== "disconnected") return;
if (!gatewayUrl.trim()) return;
didAutoConnectRef.current = true;
void connect();
}, [connect, gatewayUrl, settingsLoaded, status]);
const setGatewayUrl = useCallback(
(value: string) => {
setGatewayUrlState(value);
manualDisconnectRef.current = false;
setStatus("disconnected");
setError(null);
settingsCoordinator.schedulePatch({ gateway: { url: value, token } }, 350);
},
[settingsCoordinator, token]
);
const setToken = useCallback(
(value: string) => {
setTokenState(value);
manualDisconnectRef.current = false;
setStatus("disconnected");
setError(null);
settingsCoordinator.schedulePatch({ gateway: { url: gatewayUrl, token: value } }, 350);
},
[gatewayUrl, settingsCoordinator]
);
const useLocalGatewayDefaults = useCallback(() => {
if (!localGatewayDefaults) return;
manualDisconnectRef.current = false;
setGatewayUrlState(localGatewayDefaults.url);
setTokenState(localGatewayDefaults.token ?? "");
setStatus("disconnected");
setError(null);
settingsCoordinator.schedulePatch(
{
gateway: { url: localGatewayDefaults.url, token: localGatewayDefaults.token ?? "" },
},
350
);
}, [localGatewayDefaults, settingsCoordinator]);
return useMemo(
() => ({
client: removedGatewayClient,
status,
gatewayUrl,
token,
localGatewayDefaults,
domainApiModeEnabled,
error,
connect,
disconnect,
useLocalGatewayDefaults,
setGatewayUrl,
setToken,
clearError,
}),
[
clearError,
connect,
disconnect,
domainApiModeEnabled,
error,
gatewayUrl,
localGatewayDefaults,
setGatewayUrl,
setToken,
status,
token,
useLocalGatewayDefaults,
]
);
};
+1 -1
View File
@@ -14,7 +14,7 @@ const isImagePath = (value: string): boolean => {
};
const toMediaUrl = (path: string): string => {
return `/api/gateway/media?path=${encodeURIComponent(path)}`;
return `/api/runtime/media?path=${encodeURIComponent(path)}`;
};
/**
-7
View File
@@ -1,12 +1,5 @@
import "@testing-library/jest-dom/vitest";
if (!process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE) {
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "false";
}
if (!process.env.STUDIO_DOMAIN_API_MODE) {
process.env.STUDIO_DOMAIN_API_MODE = "false";
}
const ensureLocalStorage = () => {
if (typeof window === "undefined") return;
const existing = window.localStorage as unknown as Record<string, unknown> | undefined;
+84 -64
View File
@@ -1,10 +1,50 @@
import { createElement } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { AgentState } from "@/features/agents/state/store";
import { AgentBrainPanel } from "@/features/agents/components/AgentInspectPanels";
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
import type { AgentFileName } from "@/lib/agents/agentFiles";
const mockState = vi.hoisted(() => {
const filesByAgent: Record<string, Record<string, string>> = {
"agent-1": {
"AGENTS.md": "alpha agents",
"SOUL.md": "# SOUL.md - Who You Are\n\n## Core Truths\n\nBe useful.",
"IDENTITY.md": "# IDENTITY.md - Who Am I?\n\n- Name: Alpha\n- Creature: droid\n- Vibe: calm\n- Emoji: 🤖\n",
"USER.md": "# USER.md - About Your Human\n\n- Name: George\n- What to call them: GP\n\n## Context\n\nBuilding OpenClaw Studio.",
"TOOLS.md": "tool notes",
"HEARTBEAT.md": "heartbeat notes",
"MEMORY.md": "durable memory",
},
"agent-2": {
"AGENTS.md": "beta agents",
},
};
const readCalls: Array<{ agentId: string; name: AgentFileName }> = [];
const writeCalls: Array<{ agentId: string; name: AgentFileName; content: string }> = [];
return { filesByAgent, readCalls, writeCalls };
});
vi.mock("@/lib/controlplane/domain-runtime-client", () => ({
readDomainAgentFile: vi.fn(async (params: { agentId: string; name: AgentFileName }) => {
mockState.readCalls.push({ agentId: params.agentId, name: params.name });
const content = mockState.filesByAgent[params.agentId]?.[params.name];
if (typeof content !== "string") {
return { exists: false, content: "" };
}
return { exists: true, content };
}),
writeDomainAgentFile: vi.fn(
async (params: { agentId: string; name: AgentFileName; content: string }) => {
mockState.writeCalls.push(params);
if (!mockState.filesByAgent[params.agentId]) {
mockState.filesByAgent[params.agentId] = {};
}
mockState.filesByAgent[params.agentId][params.name] = params.content;
}
),
}));
const createAgent = (agentId: string, name: string, sessionKey: string): AgentState => ({
agentId,
@@ -41,62 +81,17 @@ const createAgent = (agentId: string, name: string, sessionKey: string): AgentSt
avatarUrl: null,
});
const createMockClient = () => {
const filesByAgent: Record<string, Record<string, string>> = {
"agent-1": {
"AGENTS.md": "alpha agents",
"SOUL.md": "# SOUL.md - Who You Are\n\n## Core Truths\n\nBe useful.",
"IDENTITY.md": "# IDENTITY.md - Who Am I?\n\n- Name: Alpha\n- Creature: droid\n- Vibe: calm\n- Emoji: 🤖\n",
"USER.md": "# USER.md - About Your Human\n\n- Name: George\n- What to call them: GP\n\n## Context\n\nBuilding OpenClaw Studio.",
"TOOLS.md": "tool notes",
"HEARTBEAT.md": "heartbeat notes",
"MEMORY.md": "durable memory",
},
"agent-2": {
"AGENTS.md": "beta agents",
},
};
const calls: Array<{ method: string; params: unknown }> = [];
const client = {
call: vi.fn(async (method: string, params: unknown) => {
calls.push({ method, params });
if (method === "agents.files.get") {
const record = params && typeof params === "object" ? (params as Record<string, unknown>) : {};
const agentId = typeof record.agentId === "string" ? record.agentId : "";
const name = typeof record.name === "string" ? record.name : "";
const content = filesByAgent[agentId]?.[name];
if (typeof content !== "string") {
return { file: { name, missing: true } };
}
return { file: { name, missing: false, content } };
}
if (method === "agents.files.set") {
const record = params && typeof params === "object" ? (params as Record<string, unknown>) : {};
const agentId = typeof record.agentId === "string" ? record.agentId : "";
const name = typeof record.name === "string" ? record.name : "";
const content = typeof record.content === "string" ? record.content : "";
if (!filesByAgent[agentId]) {
filesByAgent[agentId] = {};
}
filesByAgent[agentId][name] = content;
return { ok: true };
}
return {};
}),
} as unknown as GatewayClient;
return { client, calls, filesByAgent };
};
describe("AgentBrainPanel", () => {
beforeEach(() => {
mockState.readCalls.length = 0;
mockState.writeCalls.length = 0;
});
afterEach(() => {
cleanup();
});
it("renders_behavior_sections_and_loads_agent_files", async () => {
const { client } = createMockClient();
const agents = [
createAgent("agent-1", "Alpha", "session-1"),
createAgent("agent-2", "Beta", "session-2"),
@@ -104,7 +99,7 @@ describe("AgentBrainPanel", () => {
render(
createElement(AgentBrainPanel, {
client,
gatewayStatus: "connected",
agents,
selectedAgentId: "agent-1",
})
@@ -125,12 +120,11 @@ describe("AgentBrainPanel", () => {
});
it("shows_actionable_message_when_session_key_missing", async () => {
const { client } = createMockClient();
const agents = [createAgent("", "Alpha", "session-1")];
render(
createElement(AgentBrainPanel, {
client,
gatewayStatus: "connected",
agents,
selectedAgentId: "",
})
@@ -142,12 +136,11 @@ describe("AgentBrainPanel", () => {
});
it("saves_updated_behavior_files", async () => {
const { client, calls, filesByAgent } = createMockClient();
const agents = [createAgent("agent-1", "Alpha", "session-1")];
render(
createElement(AgentBrainPanel, {
client,
gatewayStatus: "connected",
agents,
selectedAgentId: "agent-1",
})
@@ -166,18 +159,17 @@ describe("AgentBrainPanel", () => {
fireEvent.click(saveButton);
await waitFor(() => {
expect(calls.some((entry) => entry.method === "agents.files.set")).toBe(true);
expect(mockState.writeCalls.length).toBeGreaterThan(0);
});
expect(filesByAgent["agent-1"]["AGENTS.md"]).toBe("alpha directives updated");
expect(mockState.filesByAgent["agent-1"]["AGENTS.md"]).toBe("alpha directives updated");
});
it("discards_unsaved_changes_without_writing_files", async () => {
const { client, calls } = createMockClient();
const agents = [createAgent("agent-1", "Alpha", "session-1")];
render(
createElement(AgentBrainPanel, {
client,
gatewayStatus: "connected",
agents,
selectedAgentId: "agent-1",
})
@@ -194,16 +186,15 @@ describe("AgentBrainPanel", () => {
fireEvent.click(screen.getByRole("button", { name: "Discard" }));
expect(screen.getByLabelText("Name")).toHaveValue("Alpha");
expect(calls.some((entry) => entry.method === "agents.files.set")).toBe(false);
expect(mockState.writeCalls.length).toBe(0);
});
it("does_not_render_name_editor_in_personality_panel", async () => {
const { client } = createMockClient();
const agents = [createAgent("agent-1", "Alpha", "session-1")];
render(
createElement(AgentBrainPanel, {
client,
gatewayStatus: "connected",
agents,
selectedAgentId: "agent-1",
})
@@ -215,4 +206,33 @@ describe("AgentBrainPanel", () => {
expect(screen.queryByLabelText("Agent name")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Update Name" })).not.toBeInTheDocument();
});
it("loads_files_after_gateway_connects", async () => {
const agents = [createAgent("agent-1", "Alpha", "session-1")];
const view = render(
createElement(AgentBrainPanel, {
gatewayStatus: "connecting",
agents,
selectedAgentId: "agent-1",
})
);
await waitFor(() => {
expect(screen.getByRole("heading", { name: "Persona" })).toBeInTheDocument();
});
expect(mockState.readCalls.length).toBe(0);
view.rerender(
createElement(AgentBrainPanel, {
gatewayStatus: "connected",
agents,
selectedAgentId: "agent-1",
})
);
await waitFor(() => {
expect(mockState.readCalls.length).toBeGreaterThan(0);
});
});
});
+6 -6
View File
@@ -5,7 +5,7 @@ import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { POST, PUT } from "@/app/api/gateway/agent-state/route";
import { POST, PUT } from "@/app/api/runtime/agent-state/route";
const ORIGINAL_ENV = { ...process.env };
@@ -56,7 +56,7 @@ describe("agent state route", () => {
it("rejects missing agentId", async () => {
const response = await POST(
new Request("http://localhost/api/gateway/agent-state", {
new Request("http://localhost/api/runtime/agent-state", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({}),
@@ -67,7 +67,7 @@ describe("agent state route", () => {
it("rejects unsafe agentId", async () => {
const response = await POST(
new Request("http://localhost/api/gateway/agent-state", {
new Request("http://localhost/api/runtime/agent-state", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ agentId: "../nope" }),
@@ -87,7 +87,7 @@ describe("agent state route", () => {
} as never);
const response = await POST(
new Request("http://localhost/api/gateway/agent-state", {
new Request("http://localhost/api/runtime/agent-state", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ agentId: "my-agent" }),
@@ -130,7 +130,7 @@ describe("agent state route", () => {
} as never);
const response = await PUT(
new Request("http://localhost/api/gateway/agent-state", {
new Request("http://localhost/api/runtime/agent-state", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ agentId: "my-agent", trashDir: "/tmp/trash" }),
@@ -167,7 +167,7 @@ describe("agent state route", () => {
} as never);
const response = await POST(
new Request("http://localhost/api/gateway/agent-state", {
new Request("http://localhost/api/runtime/agent-state", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ agentId: "my-agent" }),
+2 -15
View File
@@ -141,30 +141,17 @@ describe("control-plane runtime", () => {
expect(a).toBe(b);
});
it("parses STUDIO_DOMAIN_API_MODE values", () => {
it("always enables domain mode", () => {
delete process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE;
process.env.STUDIO_DOMAIN_API_MODE = "true";
expect(isStudioDomainApiModeEnabled()).toBe(true);
process.env.STUDIO_DOMAIN_API_MODE = "1";
expect(isStudioDomainApiModeEnabled()).toBe(true);
process.env.STUDIO_DOMAIN_API_MODE = "false";
expect(isStudioDomainApiModeEnabled()).toBe(false);
expect(isStudioDomainApiModeEnabled()).toBe(true);
delete process.env.STUDIO_DOMAIN_API_MODE;
expect(isStudioDomainApiModeEnabled()).toBe(true);
});
it("falls back to NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE when server mode is unset", () => {
delete process.env.STUDIO_DOMAIN_API_MODE;
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "false";
expect(isStudioDomainApiModeEnabled()).toBe(false);
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "true";
expect(isStudioDomainApiModeEnabled()).toBe(true);
});
it("prefers STUDIO_DOMAIN_API_MODE over NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE", () => {
process.env.STUDIO_DOMAIN_API_MODE = "false";
process.env.NEXT_PUBLIC_STUDIO_DOMAIN_API_MODE = "true";
expect(isStudioDomainApiModeEnabled()).toBe(false);
});
});
+3 -3
View File
@@ -278,7 +278,7 @@ describe("delete agent via studio operation", () => {
it("uses domain cron intents instead of browser cron rpc when domain intents are enabled", async () => {
const fetchJson: FetchJson = vi.fn(async (input, init) => {
if (input === "/api/gateway/agent-state" && init?.method === "POST") {
if (input === "/api/runtime/agent-state" && init?.method === "POST") {
return { result: createTrashResult() } as never;
}
if (input === "/api/intents/cron-remove-agent" && init?.method === "POST") {
@@ -338,7 +338,7 @@ describe("delete agent via studio operation", () => {
const backups = [createCronRestoreInput("Job R", "agent-1")];
const callOrder: string[] = [];
const fetchJson: FetchJson = vi.fn(async (input, init) => {
if (input === "/api/gateway/agent-state" && init?.method === "POST") {
if (input === "/api/runtime/agent-state" && init?.method === "POST") {
callOrder.push("trash");
return {
result: createTrashResult({
@@ -355,7 +355,7 @@ describe("delete agent via studio operation", () => {
callOrder.push("cron-restore");
return { ok: true, payload: { restored: backups.length } } as never;
}
if (input === "/api/gateway/agent-state" && init?.method === "PUT") {
if (input === "/api/runtime/agent-state" && init?.method === "PUT") {
callOrder.push("state-restore");
return { result: { restored: [] } } as never;
}
-136
View File
@@ -1,136 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { GatewayBrowserClient } from "@/lib/gateway/openclaw/GatewayBrowserClient";
const UUID_V4_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
class MockWebSocket {
static OPEN = 1;
static CLOSED = 3;
static instances: MockWebSocket[] = [];
static sent: string[] = [];
static closes: Array<{ code: number; reason: string }> = [];
readyState = MockWebSocket.OPEN;
onopen: (() => void) | null = null;
onmessage: ((event: MessageEvent) => void) | null = null;
onclose: ((event: CloseEvent) => void) | null = null;
onerror: (() => void) | null = null;
constructor(public url: string) {
MockWebSocket.instances.push(this);
}
send(data: string) {
MockWebSocket.sent.push(String(data));
}
close(code?: number, reason?: string) {
MockWebSocket.closes.push({ code: code ?? 1000, reason: reason ?? "" });
this.readyState = MockWebSocket.CLOSED;
this.onclose?.({ code: code ?? 1000, reason: reason ?? "" } as CloseEvent);
}
}
describe("GatewayBrowserClient", () => {
const originalWebSocket = globalThis.WebSocket;
const originalSubtle = globalThis.crypto?.subtle;
beforeEach(() => {
MockWebSocket.instances = [];
MockWebSocket.sent = [];
MockWebSocket.closes = [];
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
if (globalThis.crypto) {
Object.defineProperty(globalThis.crypto, "subtle", {
value: undefined,
configurable: true,
});
}
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
globalThis.WebSocket = originalWebSocket;
if (globalThis.crypto) {
Object.defineProperty(globalThis.crypto, "subtle", {
value: originalSubtle,
configurable: true,
});
}
});
it("sends connect when connect.challenge arrives", async () => {
const client = new GatewayBrowserClient({ url: "ws://example.com" });
client.start();
const ws = MockWebSocket.instances[0];
if (!ws) {
throw new Error("WebSocket not created");
}
ws.onopen?.();
expect(MockWebSocket.sent).toHaveLength(0);
ws.onmessage?.({
data: JSON.stringify({
type: "event",
event: "connect.challenge",
payload: { nonce: "abc" },
}),
} as MessageEvent);
await vi.runAllTicks();
expect(MockWebSocket.sent).toHaveLength(1);
const frame = JSON.parse(MockWebSocket.sent[0] ?? "{}");
expect(frame.type).toBe("req");
expect(frame.method).toBe("connect");
expect(typeof frame.id).toBe("string");
expect(frame.id).toMatch(UUID_V4_RE);
expect(frame.params?.client?.id).toBe("openclaw-control-ui");
});
it("truncates connect-failed close reason to websocket limit", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const client = new GatewayBrowserClient({ url: "ws://example.com", token: "secret" });
client.start();
const ws = MockWebSocket.instances[0];
if (!ws) {
throw new Error("WebSocket not created");
}
ws.onopen?.();
vi.runAllTimers();
const connectFrame = JSON.parse(MockWebSocket.sent[0] ?? "{}");
const connectId = String(connectFrame.id ?? "");
expect(connectId).toMatch(UUID_V4_RE);
ws.onmessage?.({
data: JSON.stringify({
type: "res",
id: connectId,
ok: false,
error: {
code: "INVALID_REQUEST",
message: `invalid config ${"x".repeat(260)}`,
},
}),
} as MessageEvent);
await vi.runAllTicks();
await vi.runAllTimersAsync();
await Promise.resolve();
const lastClose = MockWebSocket.closes.at(-1);
expect(lastClose?.code).toBe(4008);
expect(lastClose?.reason.startsWith("connect failed: INVALID_REQUEST")).toBe(true);
expect(new TextEncoder().encode(lastClose?.reason ?? "").byteLength).toBeLessThanOrEqual(123);
warnSpy.mockRestore();
});
});
@@ -1,52 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { GatewayResponseError } from "@/lib/gateway/errors";
import { GatewayClient } from "@/lib/gateway/GatewayClient";
let lastOpts: Record<string, unknown> | null = null;
vi.mock("@/lib/gateway/openclaw/GatewayBrowserClient", () => {
class GatewayBrowserClient {
connected = false;
constructor(opts: Record<string, unknown>) {
lastOpts = opts;
}
start() {}
stop() {}
request() {
return Promise.resolve({});
}
}
return { GatewayBrowserClient };
});
describe("GatewayClient connect failures", () => {
it("rejects connect with GatewayResponseError when close reason encodes connect failed", async () => {
const client = new GatewayClient();
const connectPromise = client.connect({ gatewayUrl: "ws://example.invalid" });
if (!lastOpts) {
throw new Error("Expected GatewayBrowserClient to be constructed");
}
const onClose = lastOpts.onClose as ((info: { code: number; reason: string }) => void) | undefined;
if (!onClose) {
throw new Error("Expected onClose callback");
}
onClose({
code: 4008,
reason:
"connect failed: studio.gateway_token_missing Upstream gateway token is not configured on the Studio host.",
});
await expect(connectPromise).rejects.toBeInstanceOf(GatewayResponseError);
await expect(connectPromise).rejects.toMatchObject({
name: "GatewayResponseError",
code: "studio.gateway_token_missing",
});
});
});
-45
View File
@@ -1,45 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { GatewayClient } from "@/lib/gateway/GatewayClient";
let lastOpts: Record<string, unknown> | null = null;
vi.mock("@/lib/gateway/openclaw/GatewayBrowserClient", () => {
class GatewayBrowserClient {
connected = true;
constructor(opts: Record<string, unknown>) {
lastOpts = opts;
}
start() {}
stop() {}
request() {
return Promise.resolve({});
}
}
return { GatewayBrowserClient };
});
describe("GatewayClient onGap", () => {
it("forwards gateway seq gaps to subscribers", async () => {
const client = new GatewayClient();
const onGap = vi.fn();
client.onGap(onGap);
const connectPromise = client.connect({ gatewayUrl: "ws://example.invalid" });
if (!lastOpts) throw new Error("Expected GatewayBrowserClient to be constructed");
const onHello = lastOpts.onHello as ((hello: unknown) => void) | undefined;
if (!onHello) throw new Error("Expected onHello callback");
onHello({} as never);
await connectPromise;
const gapCb = lastOpts.onGap as ((info: { expected: number; received: number }) => void) | undefined;
if (!gapCb) throw new Error("Expected onGap callback");
gapCb({ expected: 10, received: 13 });
expect(onGap).toHaveBeenCalledTimes(1);
expect(onGap).toHaveBeenCalledWith({ expected: 10, received: 13 });
});
});
@@ -1,134 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { GatewayClient } from "@/lib/gateway/GatewayClient";
type MockClientOptions = {
token?: unknown;
onHello?: (hello: unknown) => void;
onClose?: (info: { code: number; reason: string }) => void;
};
type MockInstance = {
opts: MockClientOptions;
stopped: boolean;
};
let instances: MockInstance[] = [];
vi.mock("@/lib/gateway/openclaw/GatewayBrowserClient", () => {
class GatewayBrowserClient {
connected = false;
private index: number;
constructor(opts: MockClientOptions) {
this.index = instances.length;
instances.push({ opts, stopped: false });
}
start() {
this.connected = true;
}
stop() {
this.connected = false;
instances[this.index]!.stopped = true;
}
request() {
return Promise.resolve({});
}
}
return { GatewayBrowserClient };
});
afterEach(() => {
instances = [];
});
describe("GatewayClient reconnect recovery", () => {
it("allows a fresh connect after unexpected close", async () => {
const client = new GatewayClient();
const statuses: string[] = [];
client.onStatus((status) => statuses.push(status));
const firstConnect = client.connect({
gatewayUrl: "ws://example.invalid",
token: "old-token",
});
const first = instances[0];
if (!first) throw new Error("Expected first GatewayBrowserClient instance");
const onHelloFirst = first.opts.onHello;
const onCloseFirst = first.opts.onClose;
if (!onHelloFirst || !onCloseFirst) {
throw new Error("Expected first instance callbacks");
}
onHelloFirst({});
await expect(firstConnect).resolves.toBeUndefined();
onCloseFirst({ code: 1012, reason: "upstream closed" });
expect(first.stopped).toBe(true);
expect(statuses.at(-1)).toBe("disconnected");
const secondConnect = client.connect({
gatewayUrl: "ws://example.invalid",
token: "new-token",
});
const second = instances[1];
if (!second) throw new Error("Expected second GatewayBrowserClient instance");
expect(second.opts.token).toBe("new-token");
const onHelloSecond = second.opts.onHello;
if (!onHelloSecond) {
throw new Error("Expected second instance onHello callback");
}
onHelloSecond({});
await expect(secondConnect).resolves.toBeUndefined();
expect(statuses.at(-1)).toBe("connected");
});
it("ignores stale onClose callbacks from old instances", async () => {
const client = new GatewayClient();
const statuses: string[] = [];
client.onStatus((status) => statuses.push(status));
const firstConnect = client.connect({ gatewayUrl: "ws://example.invalid" });
const first = instances[0];
if (!first) throw new Error("Expected first GatewayBrowserClient instance");
const onHelloFirst = first.opts.onHello;
const onCloseFirst = first.opts.onClose;
if (!onHelloFirst || !onCloseFirst) {
throw new Error("Expected first instance callbacks");
}
onHelloFirst({});
await firstConnect;
onCloseFirst({ code: 1012, reason: "upstream closed" });
const secondConnect = client.connect({ gatewayUrl: "ws://example.invalid" });
const second = instances[1];
if (!second) throw new Error("Expected second GatewayBrowserClient instance");
const onHelloSecond = second.opts.onHello;
if (!onHelloSecond) {
throw new Error("Expected second instance onHello callback");
}
onHelloSecond({});
await secondConnect;
const statusCountBeforeStaleClose = statuses.length;
onCloseFirst({ code: 1012, reason: "late stale close" });
expect(statuses.length).toBe(statusCountBeforeStaleClose);
expect(statuses.at(-1)).toBe("connected");
});
});
@@ -1,36 +0,0 @@
import { describe, expect, it } from "vitest";
import { resolveGatewayAutoRetryDelayMs } from "@/lib/gateway/GatewayClient";
describe("resolveGatewayAutoRetryDelayMs", () => {
it("does not retry when upstream gateway url is missing on Studio host", () => {
const delay = resolveGatewayAutoRetryDelayMs({
status: "disconnected",
didAutoConnect: true,
wasManualDisconnect: false,
gatewayUrl: "wss://remote.example",
errorMessage: "Gateway error (studio.gateway_url_missing): Upstream gateway URL is missing.",
connectErrorCode: "studio.gateway_url_missing",
attempt: 0,
});
expect(delay).toBeNull();
});
it("retries for non-auth connect failures", () => {
const delay = resolveGatewayAutoRetryDelayMs({
status: "disconnected",
didAutoConnect: true,
wasManualDisconnect: false,
gatewayUrl: "wss://remote.example",
errorMessage:
"Gateway error (studio.upstream_error): Failed to connect to upstream gateway WebSocket.",
connectErrorCode: "studio.upstream_error",
attempt: 0,
});
expect(delay).toBeTypeOf("number");
expect(delay).toBeGreaterThan(0);
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { parseGatewayFrame } from "@/lib/gateway/GatewayClient";
import { parseGatewayFrame } from "@/lib/gateway/gateway-frames";
describe("gateway frames", () => {
it("parses event stateVersion objects", () => {
+4 -5
View File
@@ -22,7 +22,7 @@ vi.mock("node:child_process", async () => {
const mockedSpawnSync = vi.mocked(spawnSync);
let GET: typeof import("@/app/api/gateway/media/route")["GET"];
let GET: typeof import("@/app/api/runtime/media/route")["GET"];
const makeTempDir = (name: string) => fs.mkdtempSync(path.join(os.tmpdir(), `${name}-`));
@@ -45,10 +45,10 @@ const writeStudioSettings = (stateDir: string, gatewayUrl: string) => {
};
beforeAll(async () => {
({ GET } = await import("@/app/api/gateway/media/route"));
({ GET } = await import("@/app/api/runtime/media/route"));
});
describe("/api/gateway/media route", () => {
describe("/api/runtime/media route", () => {
let tempDir: string | null = null;
beforeEach(() => {
@@ -89,7 +89,7 @@ describe("/api/gateway/media route", () => {
const remotePath = "/home/ubuntu/.openclaw/images/pic.png";
const response = await GET(
new Request(
`http://localhost/api/gateway/media?path=${encodeURIComponent(remotePath)}`
`http://localhost/api/runtime/media?path=${encodeURIComponent(remotePath)}`
)
);
@@ -124,4 +124,3 @@ describe("/api/gateway/media route", () => {
expect(options.maxBuffer).toBeGreaterThan(payloadBytes.length);
});
});
-712
View File
@@ -1,712 +0,0 @@
// @vitest-environment node
import { EventEmitter } from "node:events";
import { afterEach, describe, expect, it, vi } from "vitest";
import { WebSocket, WebSocketServer } from "ws";
const waitForEvent = <T = unknown>(
target: { once: (event: string, cb: (...args: unknown[]) => void) => void },
event: string
) =>
new Promise<T>((resolve) => {
target.once(event, (...args: unknown[]) => resolve(args as unknown as T));
});
const closeHttpServer = (server: import("node:http").Server) =>
new Promise<void>((resolve) => server.close(() => resolve()));
const closeWebSocketServer = (server: WebSocketServer) =>
new Promise<void>((resolve) => server.close(() => resolve()));
const closeWebSocket = (ws: WebSocket) =>
new Promise<void>((resolve) => {
if (ws.readyState === WebSocket.CLOSED) {
resolve();
return;
}
ws.once("close", () => resolve());
ws.close();
});
describe("createGatewayProxy", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("injects gateway token into connect request", async () => {
const upstream = new WebSocketServer({ port: 0 });
const address = upstream.address();
if (!address || typeof address === "string") {
throw new Error("expected upstream server to have a port");
}
const upstreamUrl = `ws://127.0.0.1:${address.port}`;
let seenToken: string | null = null;
let seenOrigin: string | undefined;
upstream.on("connection", (ws, req) => {
seenOrigin = req.headers.origin;
ws.on("message", (raw) => {
const parsed = JSON.parse(String(raw));
if (parsed?.method === "connect") {
seenToken = parsed?.params?.auth?.token ?? null;
ws.send(
JSON.stringify({
type: "res",
id: parsed.id,
ok: true,
payload: { type: "hello-ok", protocol: 3, auth: {} },
})
);
}
});
});
const { createGatewayProxy } = await import("../../server/gateway-proxy");
const proxyHttp = await import("node:http").then((m) => m.createServer());
const proxy = createGatewayProxy({
loadUpstreamSettings: async () => ({ url: upstreamUrl, token: "token-123" }),
allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws",
logError: () => {},
});
proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head));
await new Promise<void>((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve));
const proxyAddr = proxyHttp.address();
if (!proxyAddr || typeof proxyAddr === "string") {
throw new Error("expected proxy server to have a port");
}
const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`);
try {
await waitForEvent(browser, "open");
browser.send(
JSON.stringify({
type: "req",
id: "connect-1",
method: "connect",
params: { auth: {} },
})
);
await waitForEvent(browser, "message");
expect(seenToken).toBe("token-123");
expect(seenOrigin).toBe(`http://localhost:${address.port}`);
} finally {
for (const client of upstream.clients) {
client.close();
}
await Promise.all([
closeWebSocket(browser),
closeWebSocketServer(upstream),
closeHttpServer(proxyHttp),
]);
}
});
it("allows browser auth token passthrough when host token is missing", async () => {
const upstream = new WebSocketServer({ port: 0 });
const address = upstream.address();
if (!address || typeof address === "string") {
throw new Error("expected upstream server to have a port");
}
const upstreamUrl = `ws://127.0.0.1:${address.port}`;
let seenToken: string | null = null;
upstream.on("connection", (ws) => {
ws.on("message", (raw) => {
const parsed = JSON.parse(String(raw));
if (parsed?.method === "connect") {
seenToken = parsed?.params?.auth?.token ?? null;
ws.send(
JSON.stringify({
type: "res",
id: parsed.id,
ok: true,
payload: { type: "hello-ok", protocol: 3, auth: {} },
})
);
}
});
});
const { createGatewayProxy } = await import("../../server/gateway-proxy");
const proxyHttp = await import("node:http").then((m) => m.createServer());
const proxy = createGatewayProxy({
loadUpstreamSettings: async () => ({ url: upstreamUrl, token: "" }),
allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws",
logError: () => {},
});
proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head));
await new Promise<void>((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve));
const proxyAddr = proxyHttp.address();
if (!proxyAddr || typeof proxyAddr === "string") {
throw new Error("expected proxy server to have a port");
}
const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`);
try {
await waitForEvent(browser, "open");
browser.send(
JSON.stringify({
type: "req",
id: "connect-pass-token",
method: "connect",
params: { auth: { token: "browser-token-123" } },
})
);
const [rawMessage] = await waitForEvent<[WebSocket.RawData]>(browser, "message");
const response = JSON.parse(String(rawMessage ?? ""));
expect(response).toMatchObject({ type: "res", id: "connect-pass-token", ok: true });
expect(seenToken).toBe("browser-token-123");
} finally {
for (const client of upstream.clients) {
client.close();
}
await Promise.all([
closeWebSocket(browser),
closeWebSocketServer(upstream),
closeHttpServer(proxyHttp),
]);
}
});
it("preserves browser auth token when both browser and host tokens are present", async () => {
const upstream = new WebSocketServer({ port: 0 });
const address = upstream.address();
if (!address || typeof address === "string") {
throw new Error("expected upstream server to have a port");
}
const upstreamUrl = `ws://127.0.0.1:${address.port}`;
let seenToken: string | null = null;
upstream.on("connection", (ws) => {
ws.on("message", (raw) => {
const parsed = JSON.parse(String(raw));
if (parsed?.method === "connect") {
seenToken = parsed?.params?.auth?.token ?? null;
ws.send(
JSON.stringify({
type: "res",
id: parsed.id,
ok: true,
payload: { type: "hello-ok", protocol: 3, auth: {} },
})
);
}
});
});
const { createGatewayProxy } = await import("../../server/gateway-proxy");
const proxyHttp = await import("node:http").then((m) => m.createServer());
const proxy = createGatewayProxy({
loadUpstreamSettings: async () => ({ url: upstreamUrl, token: "host-token-456" }),
allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws",
logError: () => {},
});
proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head));
await new Promise<void>((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve));
const proxyAddr = proxyHttp.address();
if (!proxyAddr || typeof proxyAddr === "string") {
throw new Error("expected proxy server to have a port");
}
const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`);
try {
await waitForEvent(browser, "open");
browser.send(
JSON.stringify({
type: "req",
id: "connect-browser-precedence",
method: "connect",
params: { auth: { token: "browser-token-789" } },
})
);
const [rawMessage] = await waitForEvent<[WebSocket.RawData]>(browser, "message");
const response = JSON.parse(String(rawMessage ?? ""));
expect(response).toMatchObject({ type: "res", id: "connect-browser-precedence", ok: true });
expect(seenToken).toBe("browser-token-789");
} finally {
for (const client of upstream.clients) {
client.close();
}
await Promise.all([
closeWebSocket(browser),
closeWebSocketServer(upstream),
closeHttpServer(proxyHttp),
]);
}
});
it("allows browser device signature passthrough when host token is missing", async () => {
const upstream = new WebSocketServer({ port: 0 });
const address = upstream.address();
if (!address || typeof address === "string") {
throw new Error("expected upstream server to have a port");
}
const upstreamUrl = `ws://127.0.0.1:${address.port}`;
let seenToken: string | null = null;
let seenDeviceSignature: string | null = null;
let seenDeviceId: string | null = null;
let seenDevicePublicKey: string | null = null;
let seenDeviceNonce: string | null = null;
let seenDeviceSignedAt: number | null = null;
upstream.on("connection", (ws) => {
ws.on("message", (raw) => {
const parsed = JSON.parse(String(raw));
if (parsed?.method === "connect") {
seenToken = parsed?.params?.auth?.token ?? null;
seenDeviceSignature = parsed?.params?.device?.signature ?? null;
seenDeviceId = parsed?.params?.device?.id ?? null;
seenDevicePublicKey = parsed?.params?.device?.publicKey ?? null;
seenDeviceNonce = parsed?.params?.device?.nonce ?? null;
seenDeviceSignedAt = parsed?.params?.device?.signedAt ?? null;
ws.send(
JSON.stringify({
type: "res",
id: parsed.id,
ok: true,
payload: { type: "hello-ok", protocol: 3, auth: {} },
})
);
}
});
});
const { createGatewayProxy } = await import("../../server/gateway-proxy");
const proxyHttp = await import("node:http").then((m) => m.createServer());
const proxy = createGatewayProxy({
loadUpstreamSettings: async () => ({ url: upstreamUrl, token: "" }),
allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws",
logError: () => {},
});
proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head));
await new Promise<void>((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve));
const proxyAddr = proxyHttp.address();
if (!proxyAddr || typeof proxyAddr === "string") {
throw new Error("expected proxy server to have a port");
}
const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`);
try {
await waitForEvent(browser, "open");
browser.send(
JSON.stringify({
type: "req",
id: "connect-pass-device",
method: "connect",
params: {
device: {
id: "device-id-123",
publicKey: "device-public-key-123",
signature: "device-signature-123",
signedAt: Date.now(),
nonce: "device-nonce-123",
},
},
})
);
const [rawMessage] = await waitForEvent<[WebSocket.RawData]>(browser, "message");
const response = JSON.parse(String(rawMessage ?? ""));
expect(response).toMatchObject({ type: "res", id: "connect-pass-device", ok: true });
expect(seenDeviceSignature).toBe("device-signature-123");
expect(seenDeviceId).toBe("device-id-123");
expect(seenDevicePublicKey).toBe("device-public-key-123");
expect(seenDeviceNonce).toBe("device-nonce-123");
expect(typeof seenDeviceSignedAt).toBe("number");
expect(seenToken).toBeNull();
} finally {
for (const client of upstream.clients) {
client.close();
}
await Promise.all([
closeWebSocket(browser),
closeWebSocketServer(upstream),
closeHttpServer(proxyHttp),
]);
}
});
it("allows browser password passthrough when host token is missing", async () => {
const upstream = new WebSocketServer({ port: 0 });
const address = upstream.address();
if (!address || typeof address === "string") {
throw new Error("expected upstream server to have a port");
}
const upstreamUrl = `ws://127.0.0.1:${address.port}`;
let seenPassword: string | null = null;
let seenToken: string | null = null;
upstream.on("connection", (ws) => {
ws.on("message", (raw) => {
const parsed = JSON.parse(String(raw));
if (parsed?.method === "connect") {
seenPassword = parsed?.params?.auth?.password ?? null;
seenToken = parsed?.params?.auth?.token ?? null;
ws.send(
JSON.stringify({
type: "res",
id: parsed.id,
ok: true,
payload: { type: "hello-ok", protocol: 3, auth: {} },
})
);
}
});
});
const { createGatewayProxy } = await import("../../server/gateway-proxy");
const proxyHttp = await import("node:http").then((m) => m.createServer());
const proxy = createGatewayProxy({
loadUpstreamSettings: async () => ({ url: upstreamUrl, token: "" }),
allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws",
logError: () => {},
});
proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head));
await new Promise<void>((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve));
const proxyAddr = proxyHttp.address();
if (!proxyAddr || typeof proxyAddr === "string") {
throw new Error("expected proxy server to have a port");
}
const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`);
try {
await waitForEvent(browser, "open");
browser.send(
JSON.stringify({
type: "req",
id: "connect-pass-password",
method: "connect",
params: { auth: { password: "browser-password-123" } },
})
);
const [rawMessage] = await waitForEvent<[WebSocket.RawData]>(browser, "message");
const response = JSON.parse(String(rawMessage ?? ""));
expect(response).toMatchObject({ type: "res", id: "connect-pass-password", ok: true });
expect(seenPassword).toBe("browser-password-123");
expect(seenToken).toBeNull();
} finally {
for (const client of upstream.clients) {
client.close();
}
await Promise.all([
closeWebSocket(browser),
closeWebSocketServer(upstream),
closeHttpServer(proxyHttp),
]);
}
});
it("allows browser deviceToken passthrough when host token is missing", async () => {
const upstream = new WebSocketServer({ port: 0 });
const address = upstream.address();
if (!address || typeof address === "string") {
throw new Error("expected upstream server to have a port");
}
const upstreamUrl = `ws://127.0.0.1:${address.port}`;
let seenDeviceToken: string | null = null;
let seenToken: string | null = null;
upstream.on("connection", (ws) => {
ws.on("message", (raw) => {
const parsed = JSON.parse(String(raw));
if (parsed?.method === "connect") {
seenDeviceToken = parsed?.params?.auth?.deviceToken ?? null;
seenToken = parsed?.params?.auth?.token ?? null;
ws.send(
JSON.stringify({
type: "res",
id: parsed.id,
ok: true,
payload: { type: "hello-ok", protocol: 3, auth: {} },
})
);
}
});
});
const { createGatewayProxy } = await import("../../server/gateway-proxy");
const proxyHttp = await import("node:http").then((m) => m.createServer());
const proxy = createGatewayProxy({
loadUpstreamSettings: async () => ({ url: upstreamUrl, token: "" }),
allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws",
logError: () => {},
});
proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head));
await new Promise<void>((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve));
const proxyAddr = proxyHttp.address();
if (!proxyAddr || typeof proxyAddr === "string") {
throw new Error("expected proxy server to have a port");
}
const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`);
try {
await waitForEvent(browser, "open");
browser.send(
JSON.stringify({
type: "req",
id: "connect-pass-device-token",
method: "connect",
params: { auth: { deviceToken: "browser-device-token-123" } },
})
);
const [rawMessage] = await waitForEvent<[WebSocket.RawData]>(browser, "message");
const response = JSON.parse(String(rawMessage ?? ""));
expect(response).toMatchObject({ type: "res", id: "connect-pass-device-token", ok: true });
expect(seenDeviceToken).toBe("browser-device-token-123");
expect(seenToken).toBeNull();
} finally {
for (const client of upstream.clients) {
client.close();
}
await Promise.all([
closeWebSocket(browser),
closeWebSocketServer(upstream),
closeHttpServer(proxyHttp),
]);
}
});
it("returns studio.gateway_token_missing when browser auth and host token are both missing", async () => {
const upstream = new WebSocketServer({ port: 0 });
const address = upstream.address();
if (!address || typeof address === "string") {
throw new Error("expected upstream server to have a port");
}
const upstreamUrl = `ws://127.0.0.1:${address.port}`;
let upstreamConnectionCount = 0;
upstream.on("connection", () => {
upstreamConnectionCount += 1;
});
const { createGatewayProxy } = await import("../../server/gateway-proxy");
const proxyHttp = await import("node:http").then((m) => m.createServer());
const proxy = createGatewayProxy({
loadUpstreamSettings: async () => ({ url: upstreamUrl, token: "" }),
allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws",
logError: () => {},
});
proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head));
await new Promise<void>((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve));
const proxyAddr = proxyHttp.address();
if (!proxyAddr || typeof proxyAddr === "string") {
throw new Error("expected proxy server to have a port");
}
const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`);
try {
await waitForEvent(browser, "open");
const closePromise = waitForEvent<[number, Buffer]>(browser, "close");
browser.send(
JSON.stringify({
type: "req",
id: "connect-missing-token",
method: "connect",
params: { auth: {} },
})
);
const [rawMessage] = await waitForEvent<[WebSocket.RawData]>(browser, "message");
const response = JSON.parse(String(rawMessage ?? ""));
expect(response).toMatchObject({
type: "res",
id: "connect-missing-token",
ok: false,
error: { code: "studio.gateway_token_missing" },
});
const [closeCode] = await closePromise;
expect(closeCode).toBe(1011);
expect(upstreamConnectionCount).toBe(0);
} finally {
for (const client of upstream.clients) {
client.close();
}
await Promise.all([
closeWebSocket(browser),
closeWebSocketServer(upstream),
closeHttpServer(proxyHttp),
]);
}
});
it("suppresses expected close-before-open upstream race errors", async () => {
class FakeUpstreamSocket extends EventEmitter {
readyState: number = WebSocket.CONNECTING;
send() {}
close() {
this.readyState = WebSocket.CLOSED;
this.emit("error", new Error("WebSocket was closed before the connection was established"));
this.emit("close", { code: 1000, reason: "closed" });
}
}
const logError = vi.fn();
const log = vi.fn();
let upstreamSocket: FakeUpstreamSocket | null = null;
const { createGatewayProxy } = await import("../../server/gateway-proxy");
const proxyHttp = await import("node:http").then((m) => m.createServer());
const proxy = createGatewayProxy({
loadUpstreamSettings: async () => ({ url: "ws://127.0.0.1:65535", token: "token-123" }),
allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws",
log,
logError,
createUpstreamWebSocket: () => {
upstreamSocket = new FakeUpstreamSocket();
return upstreamSocket;
},
});
proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head));
await new Promise<void>((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve));
const proxyAddr = proxyHttp.address();
if (!proxyAddr || typeof proxyAddr === "string") {
throw new Error("expected proxy server to have a port");
}
const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`);
try {
await waitForEvent(browser, "open");
browser.send(
JSON.stringify({
type: "req",
id: "connect-suppress",
method: "connect",
params: { auth: {} },
})
);
await closeWebSocket(browser);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(upstreamSocket).not.toBeNull();
expect(logError).not.toHaveBeenCalled();
expect(log).toHaveBeenCalledWith("Suppressed upstream close-before-open race.");
} finally {
await closeHttpServer(proxyHttp);
}
});
it("logs and forwards unexpected upstream socket errors", async () => {
class FakeUpstreamSocket extends EventEmitter {
readyState: number = WebSocket.CONNECTING;
send() {}
close() {
this.readyState = WebSocket.CLOSED;
this.emit("close", { code: 1000, reason: "closed" });
}
}
const logError = vi.fn();
const upstreamSocket = new FakeUpstreamSocket();
const { createGatewayProxy } = await import("../../server/gateway-proxy");
const proxyHttp = await import("node:http").then((m) => m.createServer());
const proxy = createGatewayProxy({
loadUpstreamSettings: async () => ({ url: "ws://127.0.0.1:65534", token: "token-123" }),
allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws",
logError,
createUpstreamWebSocket: () => upstreamSocket,
});
proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head));
await new Promise<void>((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve));
const proxyAddr = proxyHttp.address();
if (!proxyAddr || typeof proxyAddr === "string") {
throw new Error("expected proxy server to have a port");
}
const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`);
try {
await waitForEvent(browser, "open");
browser.send(
JSON.stringify({
type: "req",
id: "connect-forward-error",
method: "connect",
params: { auth: {} },
})
);
await new Promise((resolve) => setTimeout(resolve, 0));
upstreamSocket.emit("error", new Error("socket boom"));
const [rawMessage] = await waitForEvent<[WebSocket.RawData]>(browser, "message");
const response = JSON.parse(String(rawMessage ?? ""));
expect(response).toMatchObject({
type: "res",
id: "connect-forward-error",
ok: false,
error: { code: "studio.upstream_error" },
});
expect(logError).toHaveBeenCalledTimes(1);
} finally {
await Promise.all([closeWebSocket(browser), closeHttpServer(proxyHttp)]);
}
});
it("returns structured upstream error when upstream websocket creation throws", async () => {
const logError = vi.fn();
const { createGatewayProxy } = await import("../../server/gateway-proxy");
const proxyHttp = await import("node:http").then((m) => m.createServer());
const proxy = createGatewayProxy({
loadUpstreamSettings: async () => ({ url: "ws://127.0.0.1:65534", token: "token-123" }),
allowWs: (req: { url?: string }) => req.url === "/api/gateway/ws",
logError,
createUpstreamWebSocket: () => {
throw new Error("constructor failed");
},
});
proxyHttp.on("upgrade", (req, socket, head) => proxy.handleUpgrade(req, socket, head));
await new Promise<void>((resolve) => proxyHttp.listen(0, "127.0.0.1", resolve));
const proxyAddr = proxyHttp.address();
if (!proxyAddr || typeof proxyAddr === "string") {
throw new Error("expected proxy server to have a port");
}
const browser = new WebSocket(`ws://127.0.0.1:${proxyAddr.port}/api/gateway/ws`);
try {
await waitForEvent(browser, "open");
browser.send(
JSON.stringify({
type: "req",
id: "connect-creation-throw",
method: "connect",
params: { auth: {} },
})
);
const [rawMessage] = await waitForEvent<[WebSocket.RawData]>(browser, "message");
const response = JSON.parse(String(rawMessage ?? ""));
expect(response).toMatchObject({
type: "res",
id: "connect-creation-throw",
ok: false,
error: { code: "studio.upstream_error" },
});
expect(logError).toHaveBeenCalledTimes(1);
} finally {
await Promise.all([closeWebSocket(browser), closeHttpServer(proxyHttp)]);
}
});
});
@@ -771,6 +771,58 @@ describe("gateway runtime event handler (chat)", () => {
expect(requestHistoryRefresh).not.toHaveBeenCalled();
});
it("applies aborted terminal cleanup even when runId mismatches active run", () => {
const agents = [
createAgent({
status: "running",
runId: "run-active",
runStartedAt: 900,
streamText: "still streaming",
thinkingTrace: "t",
}),
];
const dispatch = vi.fn();
const handler = createGatewayRuntimeEventHandler({
getStatus: () => "connected",
getAgents: () => agents,
dispatch,
queueLivePatch: vi.fn(),
clearPendingLivePatch: vi.fn(),
now: () => 1000,
loadSummarySnapshot: vi.fn(async () => {}),
requestHistoryRefresh: vi.fn(async () => {}),
refreshHeartbeatLatestUpdate: vi.fn(),
bumpHeartbeatTick: vi.fn(),
setTimeout: (fn, ms) => setTimeout(fn, ms) as unknown as number,
clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
isDisconnectLikeError: () => false,
logWarn: vi.fn(),
updateSpecialLatestUpdate: vi.fn(),
});
handler.handleEvent({
type: "event",
event: "chat",
payload: {
runId: "run-old",
sessionKey: agents[0]!.sessionKey,
state: "aborted",
message: { role: "assistant", content: "" },
},
});
expect(dispatch).toHaveBeenCalledWith(
expect.objectContaining({ type: "appendOutput", agentId: "agent-1", line: "Run aborted." })
);
expect(dispatch).toHaveBeenCalledWith(
expect.objectContaining({
type: "updateAgent",
agentId: "agent-1",
patch: expect.objectContaining({ status: "idle", runId: null }),
})
);
});
it("handles aborted/error by appending output and clearing stream fields", () => {
const agents = [createAgent({ status: "running", runId: "run-1", runStartedAt: 900 })];
const dispatch = vi.fn();
+2 -2
View File
@@ -7,7 +7,7 @@ describe("media-markdown", () => {
const input = "Hello\nMEDIA: /home/ubuntu/.openclaw/workspace-agent/foo.png\nDone";
const out = rewriteMediaLinesToMarkdown(input);
expect(out).toContain("![](/api/gateway/media?path=");
expect(out).toContain("![](/api/runtime/media?path=");
expect(out).toContain("MEDIA: /home/ubuntu/.openclaw/workspace-agent/foo.png");
expect(out).toContain("Hello");
expect(out).toContain("Done");
@@ -17,7 +17,7 @@ describe("media-markdown", () => {
const input = "Hello\nMEDIA:\n/home/ubuntu/.openclaw/workspace-agent/foo.png\nDone";
const out = rewriteMediaLinesToMarkdown(input);
expect(out).toContain("![](/api/gateway/media?path=");
expect(out).toContain("![](/api/runtime/media?path=");
expect(out).toContain("MEDIA: /home/ubuntu/.openclaw/workspace-agent/foo.png");
expect(out).toContain("Hello");
expect(out).toContain("Done");
+4
View File
@@ -38,6 +38,7 @@ describe("OpenClawGatewayAdapter", () => {
const upstreamUrl = `ws://127.0.0.1:${address.port}`;
let observedConnectClientId: string | null = null;
let observedConnectClientMode: string | null = null;
let observedConnectCaps: string[] | null = null;
upstream.on("connection", (ws) => {
ws.send(JSON.stringify({ type: "event", event: "connect.challenge", payload: {} }));
@@ -47,11 +48,13 @@ describe("OpenClawGatewayAdapter", () => {
method?: string;
params?: {
client?: { id?: string; mode?: string };
caps?: string[];
};
};
if (parsed?.method === "connect") {
observedConnectClientId = parsed.params?.client?.id ?? null;
observedConnectClientMode = parsed.params?.client?.mode ?? null;
observedConnectCaps = Array.isArray(parsed.params?.caps) ? parsed.params.caps : null;
ws.send(
JSON.stringify({
type: "res",
@@ -80,6 +83,7 @@ describe("OpenClawGatewayAdapter", () => {
expect(Date.now() - startedAt).toBeLessThan(2_000);
expect(observedConnectClientId).toBe("openclaw-control-ui");
expect(observedConnectClientMode).toBe("webchat");
expect(observedConnectCaps).toEqual(expect.arrayContaining(["tool-events"]));
await adapter.stop();
});
+50
View File
@@ -176,6 +176,56 @@ describe("runtime event policy", () => {
expect(intents).toEqual([{ kind: "ignore", reason: "stale-terminal-event" }]);
});
it("returns_idle_terminal_intents_for_aborted_mismatched_run_when_agent_is_running", () => {
const intents = decideRuntimeChatEvent({
agentId: "agent-1",
state: "aborted",
runId: "run-old",
role: "assistant",
activeRunId: "run-active",
agentStatus: "running",
now: 2000,
agentRunStartedAt: 900,
nextThinking: null,
nextText: null,
hasThinkingStarted: true,
isClosedRun: false,
isStaleTerminal: false,
shouldRequestHistoryRefresh: false,
shouldUpdateLastResult: false,
shouldSetRunIdle: true,
shouldSetRunError: false,
lastResultText: null,
assistantCompletionAt: null,
shouldQueueLatestUpdate: false,
latestUpdateMessage: null,
});
expect(findIntent(intents, "clearPendingLivePatch")).toEqual({
kind: "clearPendingLivePatch",
agentId: "agent-1",
});
expect(findIntent(intents, "clearRunTracking")).toEqual({
kind: "clearRunTracking",
runId: "run-old",
});
expect(findIntent(intents, "markRunClosed")).toEqual({
kind: "markRunClosed",
runId: "run-old",
});
expect(intents).toContainEqual({
kind: "dispatchUpdateAgent",
agentId: "agent-1",
patch: {
streamText: null,
thinkingTrace: null,
runStartedAt: null,
status: "idle",
runId: null,
},
});
});
it("returns_agent_preflight_intents_for_closed_or_stale_runs", () => {
const closed = decideRuntimeAgentEvent({
runId: "run-1",
+31
View File
@@ -161,6 +161,37 @@ describe("runtimeWriteTransport", () => {
expect(mockedPostStudioIntent).not.toHaveBeenCalled();
});
it("propagates runId on chat-abort when provided", async () => {
const domainCall = vi.fn(async () => ({}));
const domainTransport = createRuntimeWriteTransport({
client: { call: domainCall } as never,
useDomainIntents: true,
});
await domainTransport.chatAbort({ sessionKey: " agent:3 ", runId: " run-3 " });
expect(mockedPostStudioIntent).toHaveBeenCalledWith("/api/intents/chat-abort", {
sessionKey: "agent:3",
runId: "run-3",
});
expect(domainCall).not.toHaveBeenCalled();
mockedPostStudioIntent.mockReset();
const gatewayCall = vi.fn(async () => ({}));
const gatewayTransport = createRuntimeWriteTransport({
client: { call: gatewayCall } as never,
useDomainIntents: false,
});
await gatewayTransport.chatAbort({ sessionKey: " agent:4 ", runId: " run-4 " });
expect(gatewayCall).toHaveBeenCalledWith("chat.abort", {
sessionKey: "agent:4",
runId: "run-4",
});
expect(mockedPostStudioIntent).not.toHaveBeenCalled();
});
it("routes rename and delete by mode", async () => {
const call = vi.fn(async () => ({}));
const gatewayTransport = createRuntimeWriteTransport({
+1 -1
View File
@@ -4,7 +4,7 @@ import {
buildAgentMainSessionKey,
isSameSessionKey,
parseAgentIdFromSessionKey,
} from "@/lib/gateway/GatewayClient";
} from "@/lib/gateway/session-keys";
describe("sessionKey helpers", () => {
it("buildAgentMainSessionKey formats agent session key", () => {
+2 -4
View File
@@ -1,10 +1,8 @@
import { describe, expect, it, vi } from "vitest";
import {
isWebchatSessionMutationBlockedError,
syncGatewaySessionSettings,
} from "@/lib/gateway/GatewayClient";
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
import { isWebchatSessionMutationBlockedError } from "@/lib/gateway/gateway-disconnect";
import { syncGatewaySessionSettings } from "@/lib/gateway/session-settings-sync";
import { GatewayResponseError } from "@/lib/gateway/errors";
describe("session settings sync helper", () => {
+1 -1
View File
@@ -30,7 +30,7 @@ describe("skills remove client", () => {
managedSkillsDir: " /tmp/managed ",
});
expect(fetchMock).toHaveBeenCalledWith("/api/gateway/skills/remove", {
expect(fetchMock).toHaveBeenCalledWith("/api/intents/skills-remove", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({

Some files were not shown because too many files have changed in this diff Show More