diff --git a/apps/agenstra/backend-agent-manager/Dockerfile.vnc b/apps/agenstra/backend-agent-manager/Dockerfile.vnc index 6cbef512c..487021fca 100644 --- a/apps/agenstra/backend-agent-manager/Dockerfile.vnc +++ b/apps/agenstra/backend-agent-manager/Dockerfile.vnc @@ -8,7 +8,10 @@ # Build: npx nx run backend-agent-manager:vnc-container-image # Run: nx docker:run agenstra-backend-agent-manager -p 6080:6080 # Notes: Required runtime env: VNC_PASSWORD -# Optional: VNC_DISPLAY, VNC_RESOLUTION, VNC_DEPTH +# Optional: VNC_DISPLAY, VNC_RESOLUTION, VNC_DEPTH, BROWSER_PREVIEW_ENABLED +# When BROWSER_PREVIEW_ENABLED=true, Chromium CDP is available on internal port 9222 via socat +# (Chromium binds loopback :9223; 9222 is not published to the host) + # Agent workspace mount path: /home/agenstra/environment # ----------------------------------------------------------------------------- # @@ -40,6 +43,7 @@ RUN apt-get update && \ supervisor \ net-tools \ openssl \ + socat \ novnc \ websockify \ nano \ @@ -78,9 +82,22 @@ RUN mkdir -p /home/agenstra/.config/xfce4/xfconf/xfce-perchannel-xml && \ > /home/agenstra/.config/xfce4/xfconf/xfce-perchannel-xml/xfce4-settings.xml && \ chown -R agenstra:agenstra /home/agenstra/.config/xfce4 -RUN sed -i 's|Exec=/usr/bin/chromium %U|Exec=/usr/bin/chromium --no-sandbox --disable-dev-shm-usage --display=:1 %U|g' \ +RUN sed -i 's|Exec=/usr/bin/chromium %U|Exec=/usr/local/bin/chromium-launch %U|g' \ /usr/share/applications/chromium.desktop +# Chromium CDP only binds loopback; expose it on the container network via socat (see entrypoint). +RUN printf '%s\n' \ + '#!/bin/bash' \ + 'set -euo pipefail' \ + 'CHROMIUM_FLAGS=(--no-sandbox --disable-dev-shm-usage --display=:1 --window-size=1910,865 --window-position=0,0 --start-maximized)' \ + 'if [ "${BROWSER_PREVIEW_ENABLED:-}" = "true" ] || [ "${BROWSER_PREVIEW_ENABLED:-}" = "1" ]; then' \ + ' CHROMIUM_FLAGS+=(--remote-debugging-port=9223 --remote-allow-origins=*)' \ + 'fi' \ + 'exec /usr/bin/chromium "${CHROMIUM_FLAGS[@]}" "$@"' \ + > /usr/local/bin/chromium-launch && \ + chmod 755 /usr/local/bin/chromium-launch && \ + chown agenstra:agenstra /usr/local/bin/chromium-launch + RUN printf '%s\n' \ '#!/bin/bash' \ 'set -e' \ @@ -145,6 +162,10 @@ RUN printf '%s\n' \ 'fi' \ 'vncserver "${VNC_DISPLAY}" -geometry "${VNC_RESOLUTION}" -depth "${VNC_DEPTH}" -localhost no -SecurityTypes VncAuth' \ 'websockify -D --web /usr/share/novnc/ --cert "$HOME/.novnc/ssl/novnc.crt" --key "$HOME/.novnc/ssl/novnc.key" 6080 localhost:5901' \ + 'if [ "${BROWSER_PREVIEW_ENABLED:-}" = "true" ] || [ "${BROWSER_PREVIEW_ENABLED:-}" = "1" ]; then' \ + ' # Chromium listens on 127.0.0.1:9223; proxy to 0.0.0.0:9222 for manager CDP over the agent network.' \ + ' socat TCP-LISTEN:9222,fork,reuseaddr,bind=0.0.0.0 TCP:127.0.0.1:9223 &' \ + 'fi' \ 'exec tail -f /dev/null' \ > /usr/local/bin/docker-entrypoint.sh && \ chmod 755 /usr/local/bin/docker-entrypoint.sh && \ diff --git a/apps/agenstra/backend-agent-manager/docker-compose.yaml b/apps/agenstra/backend-agent-manager/docker-compose.yaml index 01c89fad5..13b1f6e1d 100644 --- a/apps/agenstra/backend-agent-manager/docker-compose.yaml +++ b/apps/agenstra/backend-agent-manager/docker-compose.yaml @@ -29,6 +29,8 @@ services: WEBSOCKET_NAMESPACE: ${WEBSOCKET_NAMESPACE:-agents} WEBSOCKET_CORS_ORIGIN: ${WEBSOCKET_CORS_ORIGIN:-*} NODE_ENV: ${NODE_ENV:-development} + # Used to join per-agent Docker networks for browser Preview (CDP). Defaults to this service's container_name. + MANAGER_CONTAINER_ID: ${MANAGER_CONTAINER_ID:-agent-manager-api} # Cursor agent configuration CURSOR_API_KEY: ${CURSOR_API_KEY:-} AGENT_DEFAULT_IMAGE: ${AGENT_DEFAULT_IMAGE:-ghcr.io/forepath/agenstra-manager-worker:latest} diff --git a/apps/agenstra/backend-agent-manager/package.json b/apps/agenstra/backend-agent-manager/package.json index 5b2cbe409..321189d60 100644 --- a/apps/agenstra/backend-agent-manager/package.json +++ b/apps/agenstra/backend-agent-manager/package.json @@ -36,6 +36,7 @@ "ssh2": "1.17.0", "sshpk": "1.18.0", "uuid": "11.1.1", + "ws": "8.18.0", "zod": "4.3.6", "@opentelemetry/api": "1.9.1", "@opentelemetry/auto-instrumentations-node": "0.79.0", diff --git a/apps/agenstra/backend-agent-manager/src/migrations/1780000000000_AddBrowserPreviewEnabledToAgentsTable.ts b/apps/agenstra/backend-agent-manager/src/migrations/1780000000000_AddBrowserPreviewEnabledToAgentsTable.ts new file mode 100644 index 000000000..4eb098a1f --- /dev/null +++ b/apps/agenstra/backend-agent-manager/src/migrations/1780000000000_AddBrowserPreviewEnabledToAgentsTable.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +/** + * Adds browser_preview_enabled to agents. + * Existing agents with a VNC sidecar get preview enabled (VNC implies preview). + */ +export class AddBrowserPreviewEnabledToAgentsTable1780000000000 implements MigrationInterface { + name = 'AddBrowserPreviewEnabledToAgentsTable1780000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumn( + 'agents', + new TableColumn({ + name: 'browser_preview_enabled', + type: 'boolean', + isNullable: false, + default: false, + }), + ); + + await queryRunner.query(` + UPDATE agents + SET browser_preview_enabled = true + WHERE vnc_container_id IS NOT NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumn('agents', 'browser_preview_enabled'); + } +} diff --git a/apps/agenstra/frontend-agent-console/src/i18n/messages.de.xlf b/apps/agenstra/frontend-agent-console/src/i18n/messages.de.xlf index b1dce09eb..971167005 100644 --- a/apps/agenstra/frontend-agent-console/src/i18n/messages.de.xlf +++ b/apps/agenstra/frontend-agent-console/src/i18n/messages.de.xlf @@ -1285,6 +1285,90 @@ Create SSH Connection SSH-Verbindung erstellen + + Enable Browser Preview + Browser-Vorschau aktivieren + + + Stream and control Chromium in the virtual workspace without full desktop VNC access. Defaults to enabled. Enabling VNC always includes Preview. + Chromium im virtuellen Workspace streamen und steuern, ohne vollen Desktop-VNC-Zugriff. Standardmäßig aktiviert. VNC schließt die Vorschau immer mit ein. + + + Open browser Preview + Browser-Vorschau öffnen + + + Open virtual desktop + Virtuellen Desktop öffnen + + + Browser Preview + Browser-Vorschau + + + Close + Schließen + + + Connecting to browser… + Verbinde mit dem Browser… + + + Back + Zurück + + + Forward + Vor + + + Reload + Neu laden + + + Address + Adresse + + + https://example.com + https://example.com + + + Go + Los + + + How to use Browser Preview + So nutzt du die Browser-Vorschau + + + Enter a URL in the address bar and press Go (or Enter) to open a page in this virtual browser. + Gib eine URL in die Adressleiste ein und drücke Los (oder Enter), um eine Seite in diesem virtuellen Browser zu öffnen. + + + Click, scroll, and type on the stream below to control the remote browser. Use Back, Forward, and Reload like a normal browser. + Klicke, scrolle und tippe auf dem Stream darunter, um den Remote-Browser zu steuern. Nutze Zurück, Vor und Neu laden wie in einem normalen Browser. + + + Close this window when you are done. Each open starts a fresh browser tab. + Schließe dieses Fenster, wenn du fertig bist. Jedes Öffnen startet einen neuen Browser-Tab. + + + Open an app running in the workspace + App im Workspace öffnen + + + Preview shares a private Docker network with the workspace container. localhost here is the Preview browser itself—not the workspace. Bind your app to 0.0.0.0 (all interfaces), then open it with the workspace hostname and port. + Die Vorschau teilt sich ein privates Docker-Netzwerk mit dem Workspace-Container. localhost meint hier den Vorschau-Browser selbst—nicht den Workspace. Binde deine App an 0.0.0.0 (alle Interfaces) und öffne sie dann mit dem Workspace-Hostnamen und Port. + + + Example: + Beispiel: + + + Use http://<workspace-container-name>:<port>. Ask the agent or run hostname in a workspace terminal to get the container name. + Verwende http://<workspace-container-name>:<port>. Frage den Agenten oder führe hostname in einem Workspace-Terminal aus, um den Containernamen zu erhalten. + Enable SSH access to the environment container. Defaults to disabled. SSH-Zugang zum Umgebungscontainer aktivieren. Standardmäßig deaktiviert. diff --git a/apps/agenstra/frontend-agent-console/src/i18n/messages.xlf b/apps/agenstra/frontend-agent-console/src/i18n/messages.xlf index 78e56072f..0405651bb 100644 --- a/apps/agenstra/frontend-agent-console/src/i18n/messages.xlf +++ b/apps/agenstra/frontend-agent-console/src/i18n/messages.xlf @@ -963,6 +963,69 @@ Enable VNC access to the environment's virtual workspace. Defaults to disabled. + + Enable Browser Preview + + + Stream and control Chromium in the virtual workspace without full desktop VNC access. Defaults to enabled. Enabling VNC always includes Preview. + + + Open browser Preview + + + Open virtual desktop + + + Browser Preview + + + Close + + + Connecting to browser… + + + Back + + + Forward + + + Reload + + + Address + + + https://example.com + + + Go + + + How to use Browser Preview + + + Enter a URL in the address bar and press Go (or Enter) to open a page in this virtual browser. + + + Click, scroll, and type on the stream below to control the remote browser. Use Back, Forward, and Reload like a normal browser. + + + Close this window when you are done. Each open starts a fresh browser tab. + + + Open an app running in the workspace + + + Preview shares a private Docker network with the workspace container. localhost here is the Preview browser itself—not the workspace. Bind your app to 0.0.0.0 (all interfaces), then open it with the workspace hostname and port. + + + Example: + + + Use http://<workspace-container-name>:<port>. Ask the agent or run hostname in a workspace terminal to get the container name. + Create SSH Connection diff --git a/docs/agenstra/README.md b/docs/agenstra/README.md index c0b90fbbe..ffe72ddcc 100644 --- a/docs/agenstra/README.md +++ b/docs/agenstra/README.md @@ -13,6 +13,7 @@ Agenstra is a full-stack agent management platform that lets you: - **Version Control Integration** Full Git operations (status, branches, commit, push, pull, rebase) directly from the web interface - **Container Management** Monitor and interact with agent containers, view logs, and manage container lifecycle - **VNC Browser Access** Graphical browser access via VNC with XFCE4 desktop and Chromium browser +- **Browser Preview** Browser-only Chromium control via CDP without publishing extra ports ## Documentation Structure @@ -54,6 +55,7 @@ Feature documentation: - [Web IDE](./features/web-ide.md) Monaco Editor integration for code editing - [Chat Interface](./features/chat-interface.md) AI chat functionality and message flow - [VNC Browser Access](./features/vnc-browser-access.md) Graphical browser access via VNC and noVNC +- [Browser Preview](./features/browser-preview.md) Browser-only Chromium Preview via CDP - [Authentication](./features/authentication.md) Multiple authentication methods with configurable user registration - [Atlassian import](./features/atlassian-import.md) Jira and Confluence imports into controller tickets and knowledge (admin) - [Dynamic provider plugins](./features/dynamic-provider-plugins.md) Runtime provider extensions for controller and manager (baked-in or mounted) diff --git a/docs/agenstra/deployment/environment-configuration.md b/docs/agenstra/deployment/environment-configuration.md index e5597a48a..0c1265eaa 100644 --- a/docs/agenstra/deployment/environment-configuration.md +++ b/docs/agenstra/deployment/environment-configuration.md @@ -111,6 +111,7 @@ Optional runtime extensions for provisioning and context import. See [Dynamic pr - `PORT` - HTTP API port (default: `3000`) - `WEBSOCKET_PORT` - WebSocket gateway port (default: `8080`) - `NODE_ENV` - Environment mode (`development` or `production`) +- `MANAGER_CONTAINER_ID` - Docker container ID or name of the manager API container. Used to temporarily join per-agent networks for browser Preview CDP. Defaults to compose `container_name` (`agent-manager-api`); falls back to `HOSTNAME` when unset. ### Database Configuration diff --git a/docs/agenstra/features/README.md b/docs/agenstra/features/README.md index e6921d8df..d7c9678b3 100644 --- a/docs/agenstra/features/README.md +++ b/docs/agenstra/features/README.md @@ -15,6 +15,7 @@ Agenstra provides a complete set of features for managing distributed AI agent i - **Web IDE** Monaco Editor integration for code editing - **Chat Interface** AI chat functionality with real-time responses - **VNC Browser Access** Graphical browser access via VNC and noVNC +- **Browser Preview** Browser-only Chromium stream/control via CDP (no extra published ports) - **Deployment** CI/CD pipeline management and deployment functionality - **Authentication** Multiple authentication methods with configurable user registration - **Tickets and Workspaces** Ticket boards, migration, and automation on the controller @@ -133,6 +134,18 @@ Graphical browser access via VNC and noVNC. Access a Chromium browser running in - Dedicated Docker network for container isolation - Shared workspace volume between agent and VNC containers +### [Browser Preview](./browser-preview.md) + +Browser-only Preview of Chromium via CDP over existing Socket.IO. Separately gated from full VNC; VNC implies Preview. + +**Key Capabilities**: + +- Stream and control Chromium without publishing extra host ports +- Screencast frames and input over the existing Socket.IO path +- Fresh Chromium tab per Preview session with fixed viewport +- Separately gated from full desktop VNC; enabling VNC always includes Preview +- Reach workspace apps over the shared Docker network (not `localhost` in Preview) + ### [Deployment](./deployment.md) CI/CD pipeline management and deployment functionality. Configure CI/CD providers (GitHub Actions), trigger pipeline runs, monitor their status, and view logs directly from the Agenstra console. @@ -246,6 +259,7 @@ graph TB IDE[Web IDE] Chat[Chat Interface] VNC[VNC Browser Access] + BP[Browser Preview] DEP[Deployment] AUTH[Authentication] TK[Tickets and Workspaces] @@ -261,6 +275,7 @@ graph TB AM --> IDE AM --> Chat AM --> VNC + AM --> BP AM --> DEP WS --> Chat WS --> TK diff --git a/docs/agenstra/features/agent-management.md b/docs/agenstra/features/agent-management.md index 71728a890..6b88281f5 100644 --- a/docs/agenstra/features/agent-management.md +++ b/docs/agenstra/features/agent-management.md @@ -30,10 +30,11 @@ The system will: - Generate a secure password - Create a Docker container for the agent - Clone the Git repository (if configured) into the container -- Create a VNC container (if VNC support is enabled) with XFCE4 desktop and Chromium browser +- Create a VNC sidecar (if browser Preview and/or full VNC is enabled) with XFCE4 and Chromium - Create a Docker network connecting the agent and VNC containers +- Optionally publish noVNC when full VNC is enabled; always enable CDP Preview when VNC is enabled - Store credentials in the controller for automatic login -- Return the agent details including the password and VNC information +- Return the agent details including Preview and/or VNC information **Important**: Save the password! You'll need it to authenticate with the agent via WebSocket (though the system handles this automatically). diff --git a/docs/agenstra/features/browser-preview.md b/docs/agenstra/features/browser-preview.md new file mode 100644 index 000000000..8147b0b9d --- /dev/null +++ b/docs/agenstra/features/browser-preview.md @@ -0,0 +1,72 @@ +# Browser Preview + +Browser Preview streams and controls the Chromium browser that runs inside an agent’s optional virtual workspace sidecar—without exposing the full XFCE desktop over noVNC. + +## Overview + +When an environment is created with **Enable Browser Preview** (default on): + +- The manager still deploys the virtual workspace (VNC) sidecar that hosts Chromium. +- Chromium remote-debugging listens on loopback `:9223` inside the sidecar; **socat** proxies it to container-network port `9222` (never published to the host). +- The manager joins the agent Docker network for the session and rewrites CDP WebSocket URLs to the sidecar IP. +- Each Preview open creates a **fresh Chromium tab** (`about:blank`), forces a **1910×865** viewport via CDP emulation (so a non-maximized desktop window does not skew the stream), and closes that tab when Preview stops. +- While the Preview URL is `about:blank`, the console shows an onboarding guide (toolbar usage + how to reach an app in the workspace container over the shared Docker network; `localhost` is the Preview sidecar, not the workspace). +- Screencast JPEGs are capped (about 1280×720) for Socket.IO throughput; pointer mapping still uses the logical device size from frame metadata. +- Authenticated console users open Preview from the globe toolbar control. +- Video frames and input travel over the **existing** agent-manager Socket.IO path (proxied by the agent controller)—no additional host ports. + +**Create Virtual Workspace (VNC)** remains a separate Danger Zone option that publishes noVNC on a host port. Enabling VNC **always** enables Preview (UI locks the Preview checkbox; backend forces `createBrowserPreview`). + +## Flag matrix + +| createBrowserPreview | createVirtualWorkspace | Sidecar | Host `6080` / `vnc` in API | Globe Preview | Desktop VNC button | +| -------------------- | ---------------------- | ------- | -------------------------- | ------------- | ------------------ | +| true | false | yes | no | yes | no | +| true (forced) | true | yes | yes | yes | yes | +| false | false | no | no | no | no | +| false (client) | true | yes | yes | yes (forced) | yes | + +## Architecture + +```text +Agent Console --Socket.IO--> Agent Controller --forward--> Agent Manager + | + | join agent Docker network + v + VNC sidecar :9222 (CDP) + Chromium Page.startScreencast + Input.dispatchMouseEvent / KeyEvent +``` + +### Socket.IO events (agents namespace) + +- `startBrowserPreview` / `browserPreviewStarted` +- `browserPreviewFrame` (base64 JPEG + metadata) +- `browserPreviewInput` (`kind`: `mouse` | `key`) +- `browserPreviewCommand` (`navigate` | `reload` | `back` | `forward`) +- `browserPreviewLocation` (current URL + history flags) +- `stopBrowserPreview` / `browserPreviewStopped` + +See `libs/domains/agenstra/backend/feature-agent-manager/spec/asyncapi.yaml`. + +## Security + +- Preview requires agent WebSocket login (same as terminals). +- Manager rejects sessions when `browser_preview_enabled` is false. +- CDP URL/port are never returned to clients; `9222` is not published. +- Input events are allowlisted and range-checked server-side. +- Navigate URLs are restricted to `http`/`https`. +- Screencast frames are not retained in the console Socket.IO event buffer (same treatment as `containerStats`). +- Full desktop noVNC remains separately gated via `vnc` credentials and published port. +- Set `MANAGER_CONTAINER_ID` so the manager can join the agent Docker network for CDP (compose defaults to `agent-manager-api`). + +## Data model + +- Column `agents.browser_preview_enabled` (boolean). +- Response field `browserPreview: { enabled: true }` when allowed. +- Response field `vnc: { port, password }` only when full VNC access was enabled. + +## Related + +- [VNC Browser Access](./vnc-browser-access.md) +- [Agent Management](./agent-management.md) diff --git a/docs/agenstra/features/vnc-browser-access.md b/docs/agenstra/features/vnc-browser-access.md index 756c2df39..f7836e939 100644 --- a/docs/agenstra/features/vnc-browser-access.md +++ b/docs/agenstra/features/vnc-browser-access.md @@ -1,6 +1,8 @@ # VNC Browser Access -VNC (Virtual Network Computing) browser access enables you to interact with a Chromium browser running in a virtual workspace container associated with an agent. This feature provides a graphical desktop environment accessible through a web-based noVNC client. +VNC (Virtual Network Computing) browser access enables you to interact with a Chromium browser running in a virtual workspace container associated with an agent. The full XFCE4 desktop is available through a web-based noVNC client when **Create Virtual Workspace (VNC)** is enabled. + +For **browser-only** control without exposing the full desktop, see **[Browser Preview](./browser-preview.md)**. Preview can be enabled without publishing noVNC; enabling full VNC always enables Preview as well. ## Overview diff --git a/graph/graph.json b/graph/graph.json index 92de6fe11..e66e2c3d1 100644 --- a/graph/graph.json +++ b/graph/graph.json @@ -2145,6 +2145,14 @@ "version": "11.1.1" } }, + { + "id": "package:ws", + "type": "package", + "attrs": { + "name": "ws", + "version": "8.18.0" + } + }, { "id": "package:zod", "type": "package", @@ -3551,6 +3559,24 @@ ] } }, + { + "id": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/browser-preview", + "type": "state", + "attrs": { + "path": "libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/browser-preview", + "languageOrKind": "ts", + "projectName": "agenstra-frontend-data-access-agent-console", + "sliceName": "browser-preview", + "memberFiles": [ + "browser-preview.actions.ts", + "browser-preview.effects.ts", + "browser-preview.facade.ts", + "browser-preview.reducer.ts", + "browser-preview.selectors.ts", + "browser-preview.utils.ts" + ] + } + }, { "id": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/client-agent-autonomy", "type": "state", @@ -9793,6 +9819,15 @@ "projectName": "agenstra-backend-feature-agent-manager" } }, + { + "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/browser-preview.service.ts", + "type": "service", + "attrs": { + "path": "libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/browser-preview.service.ts", + "languageOrKind": "ts", + "projectName": "agenstra-backend-feature-agent-manager" + } + }, { "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/config.service.ts", "type": "service", @@ -11969,6 +12004,14 @@ "languageOrKind": "md" } }, + { + "id": "file:docs/agenstra/features/browser-preview.md", + "type": "doc", + "attrs": { + "path": "docs/agenstra/features/browser-preview.md", + "languageOrKind": "md" + } + }, { "id": "file:docs/agenstra/features/chat-interface.md", "type": "doc", @@ -18739,6 +18782,78 @@ "specKind": "asyncapi" } }, + { + "id": "api:channel:agents/startBrowserPreview", + "type": "channel", + "attrs": { + "pathOrChannel": "agents/startBrowserPreview", + "summary": "Client starts a browser-only Preview session (CDP screencast). Requires login and browserPreview enabled.", + "specKind": "asyncapi" + } + }, + { + "id": "api:channel:agents/browserPreviewStarted", + "type": "channel", + "attrs": { + "pathOrChannel": "agents/browserPreviewStarted", + "summary": "Server acknowledges successful browser Preview session start.", + "specKind": "asyncapi" + } + }, + { + "id": "api:channel:agents/browserPreviewFrame", + "type": "channel", + "attrs": { + "pathOrChannel": "agents/browserPreviewFrame", + "summary": "Server streams JPEG screencast frames for an active Preview session.", + "specKind": "asyncapi" + } + }, + { + "id": "api:channel:agents/browserPreviewInput", + "type": "channel", + "attrs": { + "pathOrChannel": "agents/browserPreviewInput", + "summary": "Client sends mouse or keyboard input to an active Preview session.", + "specKind": "asyncapi" + } + }, + { + "id": "api:channel:agents/browserPreviewCommand", + "type": "channel", + "attrs": { + "pathOrChannel": "agents/browserPreviewCommand", + "summary": "Client sends navigate/reload/back/forward chrome commands to an active Preview session.", + "specKind": "asyncapi" + } + }, + { + "id": "api:channel:agents/browserPreviewLocation", + "type": "channel", + "attrs": { + "pathOrChannel": "agents/browserPreviewLocation", + "summary": "Server reports current Preview URL and history capability after navigation or session start.", + "specKind": "asyncapi" + } + }, + { + "id": "api:channel:agents/stopBrowserPreview", + "type": "channel", + "attrs": { + "pathOrChannel": "agents/stopBrowserPreview", + "summary": "Client stops an active browser Preview session.", + "specKind": "asyncapi" + } + }, + { + "id": "api:channel:agents/browserPreviewStopped", + "type": "channel", + "attrs": { + "pathOrChannel": "agents/browserPreviewStopped", + "summary": "Server acknowledges Preview session stop (also emitted on disconnect/teardown).", + "specKind": "asyncapi" + } + }, { "id": "api:channel:agents/containerStats", "type": "channel", @@ -21906,6 +22021,36 @@ "domain": "agenstra" } }, + { + "id": "concept:agenstra-browser-preview", + "type": "concept", + "attrs": { + "title": "Browser Preview", + "docPath": "docs/agenstra/features/browser-preview.md", + "sectionAnchor": "browser-preview", + "domain": "agenstra" + } + }, + { + "id": "concept:agenstra-flag-matrix", + "type": "concept", + "attrs": { + "title": "Flag matrix", + "docPath": "docs/agenstra/features/browser-preview.md", + "sectionAnchor": "flag-matrix", + "domain": "agenstra" + } + }, + { + "id": "concept:agenstra-data-model", + "type": "concept", + "attrs": { + "title": "Data model", + "docPath": "docs/agenstra/features/browser-preview.md", + "sectionAnchor": "data-model", + "domain": "agenstra" + } + }, { "id": "concept:agenstra-chat-interface", "type": "concept", @@ -31613,6 +31758,11 @@ "to": "package:uuid", "type": "depends_on" }, + { + "from": "project:agenstra-backend-agent-manager", + "to": "package:ws", + "type": "depends_on" + }, { "from": "project:agenstra-backend-agent-manager", "to": "package:zod", @@ -32898,6 +33048,11 @@ "to": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/agents", "type": "contains" }, + { + "from": "project:agenstra-frontend-data-access-agent-console", + "to": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/browser-preview", + "type": "contains" + }, { "from": "project:agenstra-frontend-data-access-agent-console", "to": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/client-agent-autonomy", @@ -36263,6 +36418,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents.service.ts", "type": "contains" }, + { + "from": "project:agenstra-backend-feature-agent-manager", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/browser-preview.service.ts", + "type": "contains" + }, { "from": "project:agenstra-backend-feature-agent-manager", "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/config.service.ts", @@ -40223,6 +40383,46 @@ "to": "api:channel:agents/terminalClosed", "type": "contains" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/spec/asyncapi.yaml", + "to": "api:channel:agents/startBrowserPreview", + "type": "contains" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/spec/asyncapi.yaml", + "to": "api:channel:agents/browserPreviewStarted", + "type": "contains" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/spec/asyncapi.yaml", + "to": "api:channel:agents/browserPreviewFrame", + "type": "contains" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/spec/asyncapi.yaml", + "to": "api:channel:agents/browserPreviewInput", + "type": "contains" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/spec/asyncapi.yaml", + "to": "api:channel:agents/browserPreviewCommand", + "type": "contains" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/spec/asyncapi.yaml", + "to": "api:channel:agents/browserPreviewLocation", + "type": "contains" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/spec/asyncapi.yaml", + "to": "api:channel:agents/stopBrowserPreview", + "type": "contains" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/spec/asyncapi.yaml", + "to": "api:channel:agents/browserPreviewStopped", + "type": "contains" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-manager/spec/asyncapi.yaml", "to": "api:channel:agents/containerStats", @@ -42293,6 +42493,41 @@ "to": "concept:agenstra-related-documentation", "type": "contains" }, + { + "from": "file:docs/agenstra/features/browser-preview.md", + "to": "concept:agenstra-browser-preview", + "type": "contains" + }, + { + "from": "file:docs/agenstra/features/browser-preview.md", + "to": "concept:agenstra-overview", + "type": "contains" + }, + { + "from": "file:docs/agenstra/features/browser-preview.md", + "to": "concept:agenstra-flag-matrix", + "type": "contains" + }, + { + "from": "file:docs/agenstra/features/browser-preview.md", + "to": "concept:agenstra-architecture", + "type": "contains" + }, + { + "from": "file:docs/agenstra/features/browser-preview.md", + "to": "concept:agenstra-security", + "type": "contains" + }, + { + "from": "file:docs/agenstra/features/browser-preview.md", + "to": "concept:agenstra-data-model", + "type": "contains" + }, + { + "from": "file:docs/agenstra/features/browser-preview.md", + "to": "concept:agenstra-related", + "type": "contains" + }, { "from": "file:docs/agenstra/features/chat-interface.md", "to": "concept:agenstra-chat-interface", @@ -53153,6 +53388,86 @@ "to": "api:channel:agents/terminalClosed", "type": "implements" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts", + "to": "api:channel:agents/startBrowserPreview", + "type": "implements" + }, + { + "from": "project:agenstra-backend-feature-agent-manager", + "to": "api:channel:agents/startBrowserPreview", + "type": "implements" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts", + "to": "api:channel:agents/browserPreviewStarted", + "type": "implements" + }, + { + "from": "project:agenstra-backend-feature-agent-manager", + "to": "api:channel:agents/browserPreviewStarted", + "type": "implements" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts", + "to": "api:channel:agents/browserPreviewFrame", + "type": "implements" + }, + { + "from": "project:agenstra-backend-feature-agent-manager", + "to": "api:channel:agents/browserPreviewFrame", + "type": "implements" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts", + "to": "api:channel:agents/browserPreviewInput", + "type": "implements" + }, + { + "from": "project:agenstra-backend-feature-agent-manager", + "to": "api:channel:agents/browserPreviewInput", + "type": "implements" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts", + "to": "api:channel:agents/browserPreviewCommand", + "type": "implements" + }, + { + "from": "project:agenstra-backend-feature-agent-manager", + "to": "api:channel:agents/browserPreviewCommand", + "type": "implements" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts", + "to": "api:channel:agents/browserPreviewLocation", + "type": "implements" + }, + { + "from": "project:agenstra-backend-feature-agent-manager", + "to": "api:channel:agents/browserPreviewLocation", + "type": "implements" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts", + "to": "api:channel:agents/stopBrowserPreview", + "type": "implements" + }, + { + "from": "project:agenstra-backend-feature-agent-manager", + "to": "api:channel:agents/stopBrowserPreview", + "type": "implements" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts", + "to": "api:channel:agents/browserPreviewStopped", + "type": "implements" + }, + { + "from": "project:agenstra-backend-feature-agent-manager", + "to": "api:channel:agents/browserPreviewStopped", + "type": "implements" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts", "to": "api:channel:agents/containerStats", @@ -57013,6 +57328,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agents.repository.ts", "type": "injects" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/browser-preview.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agents/cursor-agent.provider.ts", "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/acp-agent-messaging.service.ts", @@ -57148,6 +57468,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/deployments.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/browser-preview.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/docker.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/deployments.service.ts", "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/deployment-configurations.repository.ts", @@ -59163,6 +59488,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents.service.ts", "type": "provides" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/browser-preview.service.ts", + "type": "provides" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.ts", "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-messages.service.ts", @@ -68628,6 +68958,11 @@ "to": "domain:agenstra", "type": "belongs_to" }, + { + "from": "file:docs/agenstra/features/browser-preview.md", + "to": "domain:agenstra", + "type": "belongs_to" + }, { "from": "file:docs/agenstra/features/chat-interface.md", "to": "domain:agenstra", @@ -70358,6 +70693,21 @@ "to": "domain:agenstra", "type": "belongs_to" }, + { + "from": "concept:agenstra-browser-preview", + "to": "domain:agenstra", + "type": "belongs_to" + }, + { + "from": "concept:agenstra-flag-matrix", + "to": "domain:agenstra", + "type": "belongs_to" + }, + { + "from": "concept:agenstra-data-model", + "to": "domain:agenstra", + "type": "belongs_to" + }, { "from": "concept:agenstra-chat-interface", "to": "domain:agenstra", diff --git a/libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml b/libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml index d98dbda6b..c9326b178 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml +++ b/libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml @@ -5012,7 +5012,15 @@ components: createVirtualWorkspace: type: boolean default: true - description: Whether to create a VNC virtual workspace container (defaults to true) + description: | + Whether to publish full noVNC desktop access (defaults to true in API; console UI defaults to false). + When true, browser Preview is always enabled as well. + createBrowserPreview: + type: boolean + default: true + description: | + Whether to deploy the virtual workspace sidecar and enable browser-only Preview via CDP + (defaults to true). Forced true when createVirtualWorkspace is true. createSshConnection: type: boolean default: true @@ -5045,6 +5053,31 @@ components: description: Agent provider type identifier capabilities: $ref: '#/components/schemas/AgentTypeCapabilities' + browserPreview: + type: object + nullable: true + description: Present when browser-only Preview is enabled + properties: + enabled: + type: boolean + enum: [true] + vnc: + type: object + nullable: true + description: Present only when full noVNC desktop access was enabled + properties: + port: + type: integer + password: + type: string + ssh: + type: object + nullable: true + properties: + port: + type: integer + password: + type: string git: type: [object, 'null'] properties: diff --git a/libs/domains/agenstra/backend/feature-agent-manager/spec/asyncapi.yaml b/libs/domains/agenstra/backend/feature-agent-manager/spec/asyncapi.yaml index fe804effa..aedc25f96 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/spec/asyncapi.yaml +++ b/libs/domains/agenstra/backend/feature-agent-manager/spec/asyncapi.yaml @@ -165,6 +165,54 @@ channels: terminalClosedEvent: $ref: '#/components/messages/TerminalClosed' description: Server acknowledges terminal closure. Also emitted when terminal session ends (e.g., exit command). + agents/startBrowserPreview: + address: agents/startBrowserPreview + messages: + startBrowserPreviewCommand: + $ref: '#/components/messages/StartBrowserPreview' + description: Client starts a browser-only Preview session (CDP screencast). Requires login and browserPreview enabled. + agents/browserPreviewStarted: + address: agents/browserPreviewStarted + messages: + browserPreviewStartedEvent: + $ref: '#/components/messages/BrowserPreviewStarted' + description: Server acknowledges successful browser Preview session start. + agents/browserPreviewFrame: + address: agents/browserPreviewFrame + messages: + browserPreviewFrameEvent: + $ref: '#/components/messages/BrowserPreviewFrame' + description: Server streams JPEG screencast frames for an active Preview session. + agents/browserPreviewInput: + address: agents/browserPreviewInput + messages: + browserPreviewInputCommand: + $ref: '#/components/messages/BrowserPreviewInput' + description: Client sends mouse or keyboard input to an active Preview session. + agents/browserPreviewCommand: + address: agents/browserPreviewCommand + messages: + browserPreviewCommandCommand: + $ref: '#/components/messages/BrowserPreviewCommand' + description: Client sends navigate/reload/back/forward chrome commands to an active Preview session. + agents/browserPreviewLocation: + address: agents/browserPreviewLocation + messages: + browserPreviewLocationEvent: + $ref: '#/components/messages/BrowserPreviewLocation' + description: Server reports current Preview URL and history capability after navigation or session start. + agents/stopBrowserPreview: + address: agents/stopBrowserPreview + messages: + stopBrowserPreviewCommand: + $ref: '#/components/messages/StopBrowserPreview' + description: Client stops an active browser Preview session. + agents/browserPreviewStopped: + address: agents/browserPreviewStopped + messages: + browserPreviewStoppedEvent: + $ref: '#/components/messages/BrowserPreviewStopped' + description: Server acknowledges Preview session stop (also emitted on disconnect/teardown). agents/containerStats: address: agents/containerStats messages: @@ -312,6 +360,54 @@ operations: $ref: '#/channels/agents~1terminalClosed' messages: - $ref: '#/channels/agents~1terminalClosed/messages/terminalClosedEvent' + clientSendsStartBrowserPreview: + action: send + channel: + $ref: '#/channels/agents~1startBrowserPreview' + messages: + - $ref: '#/channels/agents~1startBrowserPreview/messages/startBrowserPreviewCommand' + serverEmitsBrowserPreviewStarted: + action: receive + channel: + $ref: '#/channels/agents~1browserPreviewStarted' + messages: + - $ref: '#/channels/agents~1browserPreviewStarted/messages/browserPreviewStartedEvent' + serverEmitsBrowserPreviewFrame: + action: receive + channel: + $ref: '#/channels/agents~1browserPreviewFrame' + messages: + - $ref: '#/channels/agents~1browserPreviewFrame/messages/browserPreviewFrameEvent' + clientSendsBrowserPreviewInput: + action: send + channel: + $ref: '#/channels/agents~1browserPreviewInput' + messages: + - $ref: '#/channels/agents~1browserPreviewInput/messages/browserPreviewInputCommand' + clientSendsBrowserPreviewCommand: + action: send + channel: + $ref: '#/channels/agents~1browserPreviewCommand' + messages: + - $ref: '#/channels/agents~1browserPreviewCommand/messages/browserPreviewCommandCommand' + serverEmitsBrowserPreviewLocation: + action: receive + channel: + $ref: '#/channels/agents~1browserPreviewLocation' + messages: + - $ref: '#/channels/agents~1browserPreviewLocation/messages/browserPreviewLocationEvent' + clientSendsStopBrowserPreview: + action: send + channel: + $ref: '#/channels/agents~1stopBrowserPreview' + messages: + - $ref: '#/channels/agents~1stopBrowserPreview/messages/stopBrowserPreviewCommand' + serverEmitsBrowserPreviewStopped: + action: receive + channel: + $ref: '#/channels/agents~1browserPreviewStopped' + messages: + - $ref: '#/channels/agents~1browserPreviewStopped/messages/browserPreviewStoppedEvent' serverBroadcastsContainerStats: action: receive channel: @@ -1056,6 +1152,150 @@ components: timestamp: type: string description: ISO timestamp + StartBrowserPreview: + name: StartBrowserPreview + title: Start browser Preview command + contentType: application/json + payload: + type: object + properties: + sessionId: + type: string + description: Optional client-chosen session ID + BrowserPreviewStarted: + name: BrowserPreviewStarted + title: Browser Preview started event + contentType: application/json + payload: + type: object + required: [success, data, timestamp] + properties: + success: + type: boolean + enum: [true] + data: + type: object + required: [sessionId] + properties: + sessionId: + type: string + workspaceHostname: + type: string + description: Docker DNS name of the workspace container on the shared Preview network + timestamp: + type: string + BrowserPreviewFrame: + name: BrowserPreviewFrame + title: Browser Preview screencast frame + contentType: application/json + payload: + type: object + required: [success, data, timestamp] + properties: + success: + type: boolean + enum: [true] + data: + type: object + required: [sessionId, data, metadata] + properties: + sessionId: + type: string + data: + type: string + description: Base64-encoded JPEG frame + metadata: + type: object + additionalProperties: true + timestamp: + type: string + BrowserPreviewInput: + name: BrowserPreviewInput + title: Browser Preview input command + contentType: application/json + payload: + type: object + required: [sessionId, kind, event] + properties: + sessionId: + type: string + kind: + type: string + enum: [mouse, key] + event: + type: object + additionalProperties: true + description: CDP Input.dispatchMouseEvent or Input.dispatchKeyEvent fields + BrowserPreviewCommand: + name: BrowserPreviewCommand + title: Browser Preview chrome command + contentType: application/json + payload: + type: object + required: [sessionId, command] + properties: + sessionId: + type: string + command: + type: string + enum: [navigate, reload, back, forward] + url: + type: string + description: Absolute http(s) URL required when command is navigate + BrowserPreviewLocation: + name: BrowserPreviewLocation + title: Browser Preview location event + contentType: application/json + payload: + type: object + required: [success, data, timestamp] + properties: + success: + type: boolean + enum: [true] + data: + type: object + required: [sessionId, url, canGoBack, canGoForward] + properties: + sessionId: + type: string + url: + type: string + canGoBack: + type: boolean + canGoForward: + type: boolean + timestamp: + type: string + StopBrowserPreview: + name: StopBrowserPreview + title: Stop browser Preview command + contentType: application/json + payload: + type: object + required: [sessionId] + properties: + sessionId: + type: string + BrowserPreviewStopped: + name: BrowserPreviewStopped + title: Browser Preview stopped event + contentType: application/json + payload: + type: object + required: [success, data, timestamp] + properties: + success: + type: boolean + enum: [true] + data: + type: object + required: [sessionId] + properties: + sessionId: + type: string + timestamp: + type: string ContainerStats: name: ContainerStats title: Container stats event diff --git a/libs/domains/agenstra/backend/feature-agent-manager/spec/openapi.yaml b/libs/domains/agenstra/backend/feature-agent-manager/spec/openapi.yaml index 62fb49430..dcad690de 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/spec/openapi.yaml +++ b/libs/domains/agenstra/backend/feature-agent-manager/spec/openapi.yaml @@ -1540,7 +1540,15 @@ components: createVirtualWorkspace: type: boolean default: true - description: Whether to create a VNC virtual workspace container (defaults to true) + description: | + Whether to publish full noVNC desktop access (defaults to true in API; console UI defaults to false). + When true, browser Preview is always enabled as well. + createBrowserPreview: + type: boolean + default: true + description: | + Whether to deploy the virtual workspace sidecar and enable browser-only Preview via CDP + (defaults to true). Forced true when createVirtualWorkspace is true. createSshConnection: type: boolean default: true @@ -1623,6 +1631,31 @@ components: description: Agent provider type identifier capabilities: $ref: '#/components/schemas/AgentTypeCapabilities' + browserPreview: + type: object + nullable: true + description: Present when browser-only Preview is enabled + properties: + enabled: + type: boolean + enum: [true] + vnc: + type: object + nullable: true + description: Present only when full noVNC desktop access was enabled (published host port) + properties: + port: + type: integer + password: + type: string + ssh: + type: object + nullable: true + properties: + port: + type: integer + password: + type: string git: type: [object, 'null'] properties: diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/index.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/index.ts index 2e75a5a64..0a4d3165a 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/index.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/index.ts @@ -48,6 +48,7 @@ export * from './lib/repositories/agents.repository'; export * from './lib/services/agent-messages.service'; export * from './lib/services/agents-vcs.service'; export * from './lib/services/agents.service'; +export * from './lib/services/browser-preview.service'; export * from './lib/services/config.service'; export * from './lib/services/docker.service'; export * from './lib/services/workspace-configuration-overrides.service'; diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/agent-response.dto.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/agent-response.dto.ts index 9f4eeb9cd..1e6b48cc4 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/agent-response.dto.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/agent-response.dto.ts @@ -17,6 +17,11 @@ export class AgentResponseDto { * Capabilities of the agent's provider (mirrors config agentTypes capabilities). */ capabilities?: AgentTypeCapabilities; + /** Present when browser-only Preview is enabled for this agent. */ + browserPreview?: { + enabled: true; + }; + /** Present only when full noVNC desktop access was enabled (published host port). */ vnc?: { port: number; password: string; diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/create-agent.dto.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/create-agent.dto.ts index 68e0a8479..3d32170a1 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/create-agent.dto.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/create-agent.dto.ts @@ -35,6 +35,18 @@ export class CreateAgentDto { @IsString({ message: 'Git repository URL must be a string' }) gitRepositoryUrl?: string; + /** + * Deploy the virtual workspace sidecar and enable browser-only Preview (CDP). + * Forced on when createVirtualWorkspace is true. Defaults to true. + */ + @IsOptional() + @IsBoolean({ message: 'Create browser preview must be a boolean' }) + createBrowserPreview?: boolean = true; + + /** + * Publish full noVNC desktop access. Implies createBrowserPreview. + * Defaults to true in the DTO; the agent console UI defaults to false. + */ @IsOptional() @IsBoolean({ message: 'Create virtual workspace must be a boolean' }) createVirtualWorkspace?: boolean = true; diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/agent.entity.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/agent.entity.ts index d0b41f717..9b875a8db 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/agent.entity.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/agent.entity.ts @@ -63,6 +63,10 @@ export class AgentEntity { }) vncPassword?: string; + /** When true, authenticated clients may open a browser-only CDP preview session. */ + @Column({ type: 'boolean', name: 'browser_preview_enabled', default: false }) + browserPreviewEnabled!: boolean; + @Column({ type: 'varchar', length: 255, nullable: true, name: 'ssh_container_id' }) sshContainerId?: string; diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.spec.ts index 9703c03a4..dcc95e7d5 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.spec.ts @@ -13,6 +13,7 @@ import { AgentMessageEventsService } from '../services/agent-message-events.serv import { AgentMessagesService } from '../services/agent-messages.service'; import { AgentSessionHydrationService } from '../services/agent-session-hydration.service'; import { AgentsService } from '../services/agents.service'; +import { BrowserPreviewService } from '../services/browser-preview.service'; import { DockerService } from '../services/docker.service'; import { PromptContextComposerService } from '../services/prompt-context-composer.service'; @@ -44,6 +45,7 @@ describe('AgentsGateway', () => { containerId: 'container-123', agentType: 'cursor', containerType: ContainerType.GENERIC, + browserPreviewEnabled: false, createdAt: new Date('2024-01-01'), updatedAt: new Date('2024-01-01'), }; @@ -127,6 +129,13 @@ describe('AgentsGateway', () => { gitStateBroadcaster?.(agentId); }), }; + const mockBrowserPreviewService = { + startSession: jest.fn(), + stopSession: jest.fn(), + stopSessionsForSocket: jest.fn(), + hasSession: jest.fn().mockReturnValue(false), + dispatchInput: jest.fn(), + }; beforeEach(async () => { gitStateBroadcaster = undefined; @@ -173,6 +182,10 @@ describe('AgentsGateway', () => { provide: AgentGitStateBroadcastService, useValue: mockGitStateBroadcast, }, + { + provide: BrowserPreviewService, + useValue: mockBrowserPreviewService, + }, ], }).compile(); @@ -3695,4 +3708,87 @@ describe('AgentsGateway', () => { expect(parts[1].result).toMatchObject({ autoEnrichmentEnabled: true }); }); }); + + describe('browser preview', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockBrowserPreviewService.hasSession.mockReturnValue(false); + }); + + it('should reject startBrowserPreview when unauthorized', async () => { + await gateway.handleStartBrowserPreview({}, mockSocket as Socket); + + expect(mockSocket.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ + success: false, + error: expect.objectContaining({ code: 'UNAUTHORIZED' }), + }), + ); + }); + + it('should reject startBrowserPreview when preview is disabled', async () => { + (gateway as any).authenticatedClients.set(mockSocket.id, 'agent-1'); + agentsRepository.findById.mockResolvedValue({ + id: 'agent-1', + browserPreviewEnabled: false, + vncContainerId: 'vnc-1', + } as any); + + await gateway.handleStartBrowserPreview({}, mockSocket as Socket); + + expect(mockSocket.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ + error: expect.objectContaining({ code: 'PREVIEW_DISABLED' }), + }), + ); + }); + + it('should start browser preview when enabled', async () => { + (gateway as any).authenticatedClients.set(mockSocket.id, 'agent-1'); + agentsRepository.findById.mockResolvedValue({ + id: 'agent-1', + browserPreviewEnabled: true, + vncContainerId: 'vnc-1', + vncNetworkId: 'net-1', + } as any); + mockBrowserPreviewService.startSession.mockResolvedValue(undefined); + + await gateway.handleStartBrowserPreview({ sessionId: 'preview-1' }, mockSocket as Socket); + + expect(mockBrowserPreviewService.startSession).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'preview-1', + agentId: 'agent-1', + vncContainerId: 'vnc-1', + networkId: 'net-1', + }), + ); + expect(mockSocket.emit).toHaveBeenCalledWith( + 'browserPreviewStarted', + expect.objectContaining({ + success: true, + data: { sessionId: 'preview-1' }, + }), + ); + }); + + it('should reject browserPreviewInput for foreign session', async () => { + (gateway as any).authenticatedClients.set(mockSocket.id, 'agent-1'); + mockBrowserPreviewService.hasSession.mockReturnValue(false); + + await gateway.handleBrowserPreviewInput( + { sessionId: 'preview-1', kind: 'mouse', event: { type: 'mouseMoved', x: 1, y: 2 } }, + mockSocket as Socket, + ); + + expect(mockSocket.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ + error: expect.objectContaining({ code: 'PREVIEW_ERROR' }), + }), + ); + }); + }); }); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts index 68b69d7d0..0f62a0f24 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts @@ -28,6 +28,7 @@ import { AgentMessageEventsService } from '../services/agent-message-events.serv import { AgentMessagesService } from '../services/agent-messages.service'; import { AgentSessionHydrationService } from '../services/agent-session-hydration.service'; import { AgentsService } from '../services/agents.service'; +import { BrowserPreviewService } from '../services/browser-preview.service'; import { DockerService } from '../services/docker.service'; import { PromptContextComposerService } from '../services/prompt-context-composer.service'; import { ContextInjectionPayload } from '../types/context-injection.types'; @@ -104,6 +105,26 @@ interface CloseTerminalPayload { sessionId: string; } +interface StartBrowserPreviewPayload { + sessionId?: string; +} + +interface BrowserPreviewInputPayload { + sessionId: string; + kind: 'mouse' | 'key'; + event: Record; +} + +interface BrowserPreviewCommandPayload { + sessionId: string; + command: 'navigate' | 'reload' | 'back' | 'forward'; + url?: string; +} + +interface StopBrowserPreviewPayload { + sessionId: string; +} + enum ChatActor { AGENT = 'agent', USER = 'user', @@ -266,6 +287,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, private readonly promptContextComposer: PromptContextComposerService, private readonly agentSessionHydrationService: AgentSessionHydrationService, private readonly gitStateBroadcast: AgentGitStateBroadcastService, + private readonly browserPreviewService: BrowserPreviewService, ) {} onModuleInit(): void { @@ -315,6 +337,8 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, this.terminalSessionsBySocket.delete(socket.id); } + void this.browserPreviewService.stopSessionsForSocket(socket.id); + // Clean up stats interval if this was the last socket for this agent if (agentUuid) { this.cleanupStatsIntervalIfNeeded(agentUuid); @@ -2499,6 +2523,214 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, } } + /** + * Start a browser-only Preview session (CDP screencast) for the authenticated agent. + */ + @SubscribeMessage('startBrowserPreview') + async handleStartBrowserPreview(@MessageBody() data: StartBrowserPreviewPayload, @ConnectedSocket() socket: Socket) { + const agentUuid = this.authenticatedClients.get(socket.id); + + if (!agentUuid) { + socket.emit('error', createErrorResponse('Unauthorized. Please login first.', 'UNAUTHORIZED')); + + return; + } + + try { + const entity = await this.agentsRepository.findById(agentUuid); + + if (!entity?.browserPreviewEnabled || !entity.vncContainerId) { + socket.emit('error', createErrorResponse('Browser preview is not enabled for this agent', 'PREVIEW_DISABLED')); + + return; + } + + const sessionId = data.sessionId || `${socket.id}-preview-${Date.now()}`; + let workspaceHostname: string | undefined; + + if (entity.containerId) { + try { + workspaceHostname = await this.dockerService.getContainerName(entity.containerId); + } catch (hostnameError) { + const err = hostnameError as { message?: string }; + + this.logger.warn(`Could not resolve workspace hostname for agent ${agentUuid}: ${err.message}`); + } + } + + await this.browserPreviewService.startSession({ + sessionId, + agentId: agentUuid, + socketId: socket.id, + vncContainerId: entity.vncContainerId, + networkId: entity.vncNetworkId, + onFrame: (frame) => { + if (socket.connected) { + try { + socket.emit('browserPreviewFrame', createSuccessResponse(frame)); + } catch (emitError) { + this.logger.warn(`Failed to emit browser preview frame for session ${sessionId}: ${emitError}`); + } + } + }, + onLocation: (location) => { + if (socket.connected) { + try { + socket.emit('browserPreviewLocation', createSuccessResponse(location)); + } catch (emitError) { + this.logger.warn(`Failed to emit browser preview location for session ${sessionId}: ${emitError}`); + } + } + }, + onClosed: () => { + if (socket.connected) { + try { + socket.emit('browserPreviewStopped', createSuccessResponse({ sessionId })); + } catch { + // ignore + } + } + }, + }); + + socket.emit( + 'browserPreviewStarted', + createSuccessResponse({ + sessionId, + ...(workspaceHostname ? { workspaceHostname } : {}), + }), + ); + this.logger.log(`Started browser preview session ${sessionId} for agent ${agentUuid}`); + } catch (error) { + const err = error as { message?: string }; + + socket.emit('error', createErrorResponse('Error starting browser preview', 'PREVIEW_ERROR')); + this.logger.error(`Browser preview start error for agent ${agentUuid}: ${err.message}`); + } + } + + /** + * Forward mouse/keyboard input to an active browser Preview session. + */ + @SubscribeMessage('browserPreviewInput') + async handleBrowserPreviewInput(@MessageBody() data: BrowserPreviewInputPayload, @ConnectedSocket() socket: Socket) { + const agentUuid = this.authenticatedClients.get(socket.id); + + if (!agentUuid) { + socket.emit('error', createErrorResponse('Unauthorized. Please login first.', 'UNAUTHORIZED')); + + return; + } + + if (!data?.sessionId || (data.kind !== 'mouse' && data.kind !== 'key') || !data.event) { + socket.emit('error', createErrorResponse('Invalid browser preview input', 'INVALID_PAYLOAD')); + + return; + } + + if (!this.browserPreviewService.hasSession(data.sessionId, socket.id)) { + socket.emit('error', createErrorResponse('Browser preview session not found or access denied', 'PREVIEW_ERROR')); + + return; + } + + try { + await this.browserPreviewService.dispatchInput(data.sessionId, { + kind: data.kind, + event: data.event as never, + }); + } catch (error) { + const err = error as { message?: string }; + + socket.emit('error', createErrorResponse('Error sending browser preview input', 'PREVIEW_ERROR')); + this.logger.warn(`Browser preview input error for session ${data.sessionId}: ${err.message}`); + } + } + + /** + * Run a browser Preview chrome command (navigate / reload / back / forward). + */ + @SubscribeMessage('browserPreviewCommand') + async handleBrowserPreviewCommand( + @MessageBody() data: BrowserPreviewCommandPayload, + @ConnectedSocket() socket: Socket, + ) { + const agentUuid = this.authenticatedClients.get(socket.id); + + if (!agentUuid) { + socket.emit('error', createErrorResponse('Unauthorized. Please login first.', 'UNAUTHORIZED')); + + return; + } + + const allowedCommands = new Set(['navigate', 'reload', 'back', 'forward']); + + if (!data?.sessionId || !allowedCommands.has(data.command)) { + socket.emit('error', createErrorResponse('Invalid browser preview command', 'INVALID_PAYLOAD')); + + return; + } + + if (!this.browserPreviewService.hasSession(data.sessionId, socket.id)) { + socket.emit('error', createErrorResponse('Browser preview session not found or access denied', 'PREVIEW_ERROR')); + + return; + } + + try { + if (data.command === 'navigate') { + await this.browserPreviewService.dispatchCommand(data.sessionId, { + type: 'navigate', + url: typeof data.url === 'string' ? data.url : '', + }); + } else { + await this.browserPreviewService.dispatchCommand(data.sessionId, { type: data.command }); + } + } catch (error) { + const err = error as { message?: string }; + + socket.emit('error', createErrorResponse('Error running browser preview command', 'PREVIEW_ERROR')); + this.logger.warn(`Browser preview command error for session ${data.sessionId}: ${err.message}`); + } + } + + /** + * Stop an active browser Preview session. + */ + @SubscribeMessage('stopBrowserPreview') + async handleStopBrowserPreview(@MessageBody() data: StopBrowserPreviewPayload, @ConnectedSocket() socket: Socket) { + const agentUuid = this.authenticatedClients.get(socket.id); + + if (!agentUuid) { + socket.emit('error', createErrorResponse('Unauthorized. Please login first.', 'UNAUTHORIZED')); + + return; + } + + if (!data?.sessionId) { + socket.emit('error', createErrorResponse('sessionId is required', 'INVALID_PAYLOAD')); + + return; + } + + if (!this.browserPreviewService.hasSession(data.sessionId, socket.id)) { + socket.emit('error', createErrorResponse('Browser preview session not found or access denied', 'PREVIEW_ERROR')); + + return; + } + + try { + await this.browserPreviewService.stopSession(data.sessionId); + socket.emit('browserPreviewStopped', createSuccessResponse({ sessionId: data.sessionId })); + this.logger.log(`Stopped browser preview session ${data.sessionId} for agent ${agentUuid}`); + } catch (error) { + const err = error as { message?: string }; + + socket.emit('error', createErrorResponse('Error stopping browser preview', 'PREVIEW_ERROR')); + this.logger.error(`Browser preview stop error for session ${data.sessionId}: ${err.message}`); + } + } + /** * Start periodic stats broadcasting for an agent. * Sends the first stats immediately, then continues periodically. diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.ts index 2bb9dc734..f32ee3dfb 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.ts @@ -66,6 +66,7 @@ import { AgentsFiltersService } from '../services/agents-filters.service'; import { AgentsVcsService } from '../services/agents-vcs.service'; import { AgentsVerificationService } from '../services/agents-verification.service'; import { AgentsService } from '../services/agents.service'; +import { BrowserPreviewService } from '../services/browser-preview.service'; import { ConfigService } from '../services/config.service'; import { InstanceStatusService } from '../services/instance-status.service'; import { DeploymentsService } from '../services/deployments.service'; @@ -109,6 +110,7 @@ import { WorkspaceConfigurationOverridesService } from '../services/workspace-co AgenstraManagerMetricsCollectorService, AgentsGateway, AgentsService, + BrowserPreviewService, AgentMessagesService, PromptContextComposerService, AgentMessageEventsService, diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-environment-variables.repository.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-environment-variables.repository.spec.ts index 1c0cb83f3..b903bf963 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-environment-variables.repository.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-environment-variables.repository.spec.ts @@ -18,6 +18,7 @@ describe('AgentEnvironmentVariablesRepository', () => { volumePath: '/opt/agents/test-volume-uuid', agentType: 'cursor', containerType: ContainerType.GENERIC, + browserPreviewEnabled: false, createdAt: new Date(), updatedAt: new Date(), }; diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-messages.repository.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-messages.repository.spec.ts index d02ffecbc..a9dd4ad54 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-messages.repository.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-messages.repository.spec.ts @@ -18,6 +18,7 @@ describe('AgentMessagesRepository', () => { volumePath: '/opt/agents/test-volume-uuid', agentType: 'cursor', containerType: ContainerType.GENERIC, + browserPreviewEnabled: false, createdAt: new Date(), updatedAt: new Date(), }; diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agents.repository.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agents.repository.spec.ts index decc41708..c9f285623 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agents.repository.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agents.repository.spec.ts @@ -17,6 +17,7 @@ describe('AgentsRepository', () => { volumePath: '/opt/agents/test-volume-uuid', agentType: 'cursor', containerType: ContainerType.GENERIC, + browserPreviewEnabled: false, createdAt: new Date(), updatedAt: new Date(), }; diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-file-system.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-file-system.service.spec.ts index b21bb5859..23754f0cd 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-file-system.service.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-file-system.service.spec.ts @@ -33,6 +33,7 @@ describe('AgentFileSystemService', () => { }; const mockAgentEntity: AgentEntity = { ...mockAgentResponse, + browserPreviewEnabled: false, containerId: mockContainerId, hashedPassword: 'hashed-password', volumePath: '/opt/agents/test-uuid', diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents-vcs.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents-vcs.service.spec.ts index d3c5b0df9..d9d783272 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents-vcs.service.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents-vcs.service.spec.ts @@ -29,6 +29,7 @@ describe('AgentsVcsService', () => { volumePath: '/opt/agents/test-uuid', agentType: 'cursor', containerType: ContainerType.GENERIC, + browserPreviewEnabled: false, createdAt: new Date('2024-01-01'), updatedAt: new Date('2024-01-01'), }; diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents-verification.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents-verification.service.spec.ts index 748fbe495..2897742e6 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents-verification.service.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents-verification.service.spec.ts @@ -22,6 +22,7 @@ describe('AgentsVerificationService', () => { volumePath: '/v', agentType: 'cursor', containerType: ContainerType.GENERIC, + browserPreviewEnabled: false, createdAt: new Date(), updatedAt: new Date(), }; diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents.service.spec.ts index ccbe5b1f4..25dfbc751 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents.service.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents.service.spec.ts @@ -31,6 +31,7 @@ describe('AgentsService', () => { volumePath: '/opt/agents/test-volume-uuid', agentType: 'cursor', containerType: ContainerType.GENERIC, + browserPreviewEnabled: false, createdAt: new Date('2024-01-01'), updatedAt: new Date('2024-01-01'), }; @@ -286,6 +287,7 @@ describe('AgentsService', () => { vncHostPort: 50000, vncNetworkId: 'network-id', vncPassword: 'vnc-password', + browserPreviewEnabled: true, }; repository.findByName.mockResolvedValue(null); @@ -315,6 +317,7 @@ describe('AgentsService', () => { env: expect.objectContaining({ AGENT_NAME: createDto.name, VNC_PASSWORD: expect.any(String), + BROWSER_PREVIEW_ENABLED: 'true', }), volumes: [ { @@ -341,6 +344,110 @@ describe('AgentsService', () => { containerIds: [workerContainerId, vncContainerId], }), ); + expect(repository.create).toHaveBeenCalledWith( + expect.objectContaining({ + vncContainerId, + vncHostPort: expect.any(Number), + browserPreviewEnabled: true, + }), + ); + }); + + it('should create preview-only sidecar without publishing noVNC port', async () => { + const createDto: CreateAgentDto = { + name: 'Preview Agent', + createBrowserPreview: true, + createVirtualWorkspace: false, + createSshConnection: false, + containerType: ContainerType.GENERIC, + }; + const hashedPassword = 'hashed-password'; + const workerContainerId = 'worker-container-id'; + const vncContainerId = 'vnc-container-id'; + const createdAgent = { + ...mockAgent, + name: createDto.name, + hashedPassword, + containerId: workerContainerId, + volumePath: '/opt/agents/test-volume-uuid', + vncContainerId, + vncHostPort: undefined, + vncNetworkId: 'network-id', + vncPassword: 'vnc-password', + browserPreviewEnabled: true, + }; + + repository.findByName.mockResolvedValue(null); + passwordService.hashPassword.mockResolvedValue(hashedPassword); + dockerService.createContainer.mockResolvedValueOnce(workerContainerId).mockResolvedValueOnce(vncContainerId); + dockerService.sendCommandToContainer.mockResolvedValue(undefined); + dockerService.createNetwork.mockResolvedValue('network-id'); + repository.create.mockResolvedValue(createdAgent); + + const result = await service.create(createDto); + + expect(dockerService.createContainer).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + ports: [], + env: expect.objectContaining({ + BROWSER_PREVIEW_ENABLED: 'true', + }), + }), + ); + expect(repository.create).toHaveBeenCalledWith( + expect.objectContaining({ + vncContainerId, + vncHostPort: undefined, + browserPreviewEnabled: true, + }), + ); + expect(result.browserPreview).toEqual({ enabled: true }); + expect(result.vnc).toBeUndefined(); + }); + + it('should force browser preview when only virtual workspace is requested', async () => { + const createDto: CreateAgentDto = { + name: 'VNC Implies Preview', + createBrowserPreview: false, + createVirtualWorkspace: true, + createSshConnection: false, + containerType: ContainerType.GENERIC, + }; + const hashedPassword = 'hashed-password'; + const workerContainerId = 'worker-container-id'; + const vncContainerId = 'vnc-container-id'; + const createdAgent = { + ...mockAgent, + name: createDto.name, + hashedPassword, + containerId: workerContainerId, + volumePath: '/opt/agents/test-volume-uuid', + vncContainerId, + vncHostPort: 50001, + vncNetworkId: 'network-id', + vncPassword: 'vnc-password', + browserPreviewEnabled: true, + }; + + repository.findByName.mockResolvedValue(null); + repository.findPortInUse.mockResolvedValue(null); + passwordService.hashPassword.mockResolvedValue(hashedPassword); + dockerService.createContainer.mockResolvedValueOnce(workerContainerId).mockResolvedValueOnce(vncContainerId); + dockerService.sendCommandToContainer.mockResolvedValue(undefined); + dockerService.createNetwork.mockResolvedValue('network-id'); + repository.create.mockResolvedValue(createdAgent); + + const result = await service.create(createDto); + + expect(repository.create).toHaveBeenCalledWith( + expect.objectContaining({ + browserPreviewEnabled: true, + vncHostPort: expect.any(Number), + }), + ); + expect(result.browserPreview).toEqual({ enabled: true }); + expect(result.vnc?.port).toBeDefined(); }); it('should ensure docker images exist before creating SSH connection container', async () => { @@ -417,6 +524,7 @@ describe('AgentsService', () => { volumePath, agentType: 'cursor', containerType: ContainerType.GENERIC, + browserPreviewEnabled: false, createdAt: mockAgent.createdAt, updatedAt: mockAgent.updatedAt, }; diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents.service.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents.service.ts index be6eba5d2..431556c9c 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents.service.ts @@ -486,18 +486,22 @@ export class AgentsService implements OnApplicationBootstrap { }; } - // Create VNC container + // Create virtual workspace sidecar (browser Preview and/or full VNC). + // VNC access always implies Preview; Preview alone does not publish noVNC. + const createVirtualWorkspace = !!createAgentDto.createVirtualWorkspace; + const browserPreviewEnabled = !!(createAgentDto.createBrowserPreview || createVirtualWorkspace); let virtualWorkspace: | { containerId: string; - hostPort: number; + hostPort?: number; password: string; + browserPreviewEnabled: boolean; } | undefined; - if (createAgentDto.createVirtualWorkspace && virtualWorkspaceDockerImage) { - const virtualWorkspaceHostPort = await this.generateRandomVNCPort(); + if (browserPreviewEnabled && virtualWorkspaceDockerImage) { const virtualWorkspacePassword = this.generateRandomPassword(); + const virtualWorkspaceHostPort = createVirtualWorkspace ? await this.generateRandomVNCPort() : undefined; await this.dockerService.ensureImageExists(virtualWorkspaceDockerImage); @@ -508,6 +512,7 @@ export class AgentsService implements OnApplicationBootstrap { CURSOR_API_KEY: process.env.CURSOR_API_KEY, ...this.buildGitContainerEnv(gitRepositorySetupMode, repositoryUrl), VNC_PASSWORD: virtualWorkspacePassword, + BROWSER_PREVIEW_ENABLED: 'true', }, volumes: [ { @@ -521,25 +526,29 @@ export class AgentsService implements OnApplicationBootstrap { readOnly: true, }, ], - ports: [ - { - containerPort: 6080, - hostPort: virtualWorkspaceHostPort, - }, - ], + ports: + createVirtualWorkspace && virtualWorkspaceHostPort !== undefined + ? [ + { + containerPort: 6080, + hostPort: virtualWorkspaceHostPort, + }, + ] + : [], }); virtualWorkspace = { containerId: virtualWorkspaceContainerId, hostPort: virtualWorkspaceHostPort, password: virtualWorkspacePassword, + browserPreviewEnabled: true, }; } try { let networkId: string | undefined; - if (createAgentDto.createVirtualWorkspace && virtualWorkspace) { + if (browserPreviewEnabled && virtualWorkspace) { networkId = await this.dockerService.createNetwork({ name: uuidv4(), containerIds: [ @@ -559,12 +568,13 @@ export class AgentsService implements OnApplicationBootstrap { volumePath: agentVolumePath, agentType: createAgentDto.agentType || 'cursor', containerType: createAgentDto.containerType || ContainerType.GENERIC, - ...(createAgentDto.createVirtualWorkspace && + ...(browserPreviewEnabled && virtualWorkspace && { vncContainerId: virtualWorkspace.containerId, vncHostPort: virtualWorkspace.hostPort, vncNetworkId: networkId, vncPassword: virtualWorkspace.password, + browserPreviewEnabled: true, }), ...(createAgentDto.createSshConnection && sshConnection && { @@ -606,7 +616,7 @@ export class AgentsService implements OnApplicationBootstrap { } catch (error) { // Clean up the container if any step after creation fails try { - if (createAgentDto.createVirtualWorkspace && virtualWorkspace) { + if (browserPreviewEnabled && virtualWorkspace) { await this.dockerService.deleteContainer(virtualWorkspace.containerId); } @@ -983,6 +993,7 @@ export class AgentsService implements OnApplicationBootstrap { agentType: agent.agentType, containerType: agent.containerType, capabilities, + browserPreview: agent.browserPreviewEnabled ? { enabled: true } : undefined, vnc: agent.vncHostPort ? { port: agent.vncHostPort, diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/browser-preview.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/browser-preview.service.spec.ts new file mode 100644 index 000000000..02a685e10 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/browser-preview.service.spec.ts @@ -0,0 +1,138 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { BrowserPreviewService } from './browser-preview.service'; +import { DockerService } from './docker.service'; + +describe('BrowserPreviewService', () => { + let service: BrowserPreviewService; + let dockerService: jest.Mocked< + Pick< + DockerService, + 'getManagerContainerId' | 'connectContainerToNetwork' | 'disconnectContainerFromNetwork' | 'getContainerIpAddress' + > + >; + + beforeEach(async () => { + dockerService = { + getManagerContainerId: jest.fn().mockReturnValue('manager-container'), + connectContainerToNetwork: jest.fn().mockResolvedValue(undefined), + disconnectContainerFromNetwork: jest.fn().mockResolvedValue(undefined), + getContainerIpAddress: jest.fn().mockResolvedValue('10.0.0.5'), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + BrowserPreviewService, + { + provide: DockerService, + useValue: dockerService, + }, + ], + }).compile(); + + service = module.get(BrowserPreviewService); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should reject invalid mouse input types', async () => { + await expect( + service.dispatchInput('missing', { + kind: 'mouse', + event: { type: 'mousePressed', x: 1, y: 2 }, + }), + ).rejects.toThrow('Browser preview session not found'); + }); + + it('should report session ownership', () => { + expect(service.hasSession('s1', 'sock1')).toBe(false); + }); + + it('should normalize and reject invalid coordinates via private path through missing session', async () => { + await expect( + service.dispatchInput('s1', { + kind: 'mouse', + event: { type: 'not-a-type' as 'mousePressed', x: Number.NaN, y: 1 }, + }), + ).rejects.toThrow('Browser preview session not found'); + }); + + it('should stop sessions for a socket without error when none exist', async () => { + await expect(service.stopSessionsForSocket('sock-1')).resolves.toBeUndefined(); + }); + + it('should create a fresh page target and connect manager to agent network when starting a session', async () => { + const openSpy = jest.spyOn(service as never, 'openWebSocket' as never).mockResolvedValue({ + on: jest.fn(), + off: jest.fn(), + send: jest.fn(), + close: jest.fn(), + readyState: 1, + } as never); + const fetchSpy = jest.spyOn(service as never, 'fetchJson' as never).mockResolvedValue({ + id: 'target-abc', + type: 'page', + webSocketDebuggerUrl: 'ws://127.0.0.1:9223/devtools/page/abc', + } as never); + const sendSpy = jest.spyOn(service as never, 'sendCommand' as never).mockResolvedValue({ + currentIndex: 0, + entries: [{ id: 1, url: 'about:blank' }], + } as never); + const waitSpy = jest.spyOn(service as never, 'waitForCdpReady' as never).mockResolvedValue('10.0.0.5' as never); + + await service.startSession({ + sessionId: 'preview-1', + agentId: 'agent-1', + socketId: 'sock-1', + vncContainerId: 'vnc-1', + networkId: 'net-1', + onFrame: jest.fn(), + onLocation: jest.fn(), + onClosed: jest.fn(), + }); + + expect(dockerService.connectContainerToNetwork).toHaveBeenCalledWith('manager-container', 'net-1'); + expect(fetchSpy).toHaveBeenCalledWith( + 'http://10.0.0.5:9222/json/new?about%3Ablank', + expect.objectContaining({ method: 'PUT' }), + ); + expect(openSpy).toHaveBeenCalledWith('ws://10.0.0.5:9222/devtools/page/abc'); + expect(service.hasSession('preview-1', 'sock-1')).toBe(true); + expect(sendSpy).toHaveBeenCalledWith( + expect.anything(), + 'Emulation.setDeviceMetricsOverride', + expect.objectContaining({ width: 1910, height: 865 }), + ); + expect(sendSpy).toHaveBeenCalledWith( + expect.anything(), + 'Page.startScreencast', + expect.objectContaining({ maxWidth: 1280, maxHeight: 720 }), + ); + + await service.stopSession('preview-1'); + expect(dockerService.disconnectContainerFromNetwork).toHaveBeenCalledWith('manager-container', 'net-1'); + expect(service.hasSession('preview-1', 'sock-1')).toBe(false); + + openSpy.mockRestore(); + fetchSpy.mockRestore(); + sendSpy.mockRestore(); + waitSpy.mockRestore(); + }); + + it('should reject non-http navigate URLs', async () => { + (service as unknown as { sessionsById: Map }).sessionsById.set('preview-1', { + sessionId: 'preview-1', + socketId: 'sock-1', + ws: { readyState: 1 }, + pending: new Map(), + nextCommandId: 1, + onLocation: jest.fn(), + }); + + await expect( + service.dispatchCommand('preview-1', { type: 'navigate', url: 'javascript:alert(1)' }), + ).rejects.toThrow('Invalid navigation URL'); + }); +}); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/browser-preview.service.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/browser-preview.service.ts new file mode 100644 index 000000000..4817b0a55 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/browser-preview.service.ts @@ -0,0 +1,653 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { WebSocket } from 'ws'; + +import { DockerService } from './docker.service'; + +const CDP_PORT = 9222; +const MAX_FRAME_BYTES = 512_000; +/** Match a typical Preview modal content area (not full 1080p chrome). */ +const PREVIEW_VIEWPORT_WIDTH = 1910; +const PREVIEW_VIEWPORT_HEIGHT = 865; +/** Cap encoded frame size for Socket.IO; input still maps via metadata device size. */ +const SCREENCAST_MAX_WIDTH = 1280; +const SCREENCAST_MAX_HEIGHT = 720; +const SCREENCAST_EVERY_NTH = 2; +const SCREENCAST_QUALITY = 45; +const CDP_READY_TIMEOUT_MS = 60_000; +const CDP_READY_POLL_MS = 500; + +export type BrowserPreviewMouseInput = { + type: 'mousePressed' | 'mouseReleased' | 'mouseMoved' | 'mouseWheel'; + x: number; + y: number; + button?: 'none' | 'left' | 'middle' | 'right'; + buttons?: number; + clickCount?: number; + deltaX?: number; + deltaY?: number; + modifiers?: number; +}; + +export type BrowserPreviewKeyInput = { + type: 'keyDown' | 'keyUp' | 'rawKeyDown' | 'char'; + text?: string; + unmodifiedText?: string; + key?: string; + code?: string; + windowsVirtualKeyCode?: number; + nativeVirtualKeyCode?: number; + modifiers?: number; +}; + +export type BrowserPreviewInput = + | { kind: 'mouse'; event: BrowserPreviewMouseInput } + | { kind: 'key'; event: BrowserPreviewKeyInput }; + +export type BrowserPreviewFrame = { + sessionId: string; + data: string; + metadata: { + offsetTop: number; + pageScaleFactor: number; + deviceWidth: number; + deviceHeight: number; + scrollOffsetX: number; + scrollOffsetY: number; + timestamp: number; + }; +}; + +export type BrowserPreviewLocation = { + sessionId: string; + url: string; + canGoBack: boolean; + canGoForward: boolean; +}; + +export type BrowserPreviewCommand = + | { type: 'navigate'; url: string } + | { type: 'reload' } + | { type: 'back' } + | { type: 'forward' }; + +type CdpTargetInfo = { + id: string; + type?: string; + webSocketDebuggerUrl?: string; +}; + +type CdpSession = { + sessionId: string; + agentId: string; + socketId: string; + ws: WebSocket; + containerIp: string; + cdpTargetId: string; + ownsTarget: boolean; + networkId?: string; + managerContainerId?: string; + joinedNetwork: boolean; + nextCommandId: number; + pending: Map void; reject: (error: Error) => void }>; + onFrame: (frame: BrowserPreviewFrame) => void; + onLocation: (location: BrowserPreviewLocation) => void; + onClosed: () => void; +}; + +/** + * Manages browser-only Preview sessions via Chromium CDP inside the VNC sidecar. + * CDP port 9222 stays internal; the manager joins the agent Docker network for the session. + */ +@Injectable() +export class BrowserPreviewService { + private readonly logger = new Logger(BrowserPreviewService.name); + private readonly sessionsById = new Map(); + private readonly sessionsBySocket = new Map>(); + + constructor(private readonly dockerService: DockerService) {} + + async startSession(options: { + sessionId: string; + agentId: string; + socketId: string; + vncContainerId: string; + networkId?: string; + onFrame: (frame: BrowserPreviewFrame) => void; + onLocation: (location: BrowserPreviewLocation) => void; + onClosed: () => void; + }): Promise { + const { sessionId, agentId, socketId, vncContainerId, networkId, onFrame, onLocation, onClosed } = options; + + if (this.sessionsById.has(sessionId)) { + throw new Error('Browser preview session already exists'); + } + + const managerContainerId = this.dockerService.getManagerContainerId(); + let joinedNetwork = false; + let containerIp: string | undefined; + let createdTarget: CdpTargetInfo | undefined; + + if (networkId && managerContainerId) { + await this.dockerService.connectContainerToNetwork(managerContainerId, networkId); + joinedNetwork = true; + } + + try { + const ip = await this.waitForCdpReady(vncContainerId, networkId); + + containerIp = ip; + createdTarget = await this.createFreshPageTarget(ip); + + if (!createdTarget.webSocketDebuggerUrl) { + throw new Error('Chromium page CDP endpoint is not ready'); + } + + // Chromium reports loopback in webSocketDebuggerUrl; reach it via container IP + socat proxy. + const wsUrl = this.rewriteCdpWebSocketUrl(createdTarget.webSocketDebuggerUrl, ip); + const ws = await this.openWebSocket(wsUrl); + const session: CdpSession = { + sessionId, + agentId, + socketId, + ws, + containerIp: ip, + cdpTargetId: createdTarget.id, + ownsTarget: true, + networkId, + managerContainerId, + joinedNetwork, + nextCommandId: 1, + pending: new Map(), + onFrame, + onLocation, + onClosed, + }; + + ws.on('message', (data) => this.handleMessage(session, data)); + ws.on('close', () => { + void this.cleanupSession(sessionId, false); + }); + ws.on('error', () => { + void this.cleanupSession(sessionId, true); + }); + + this.sessionsById.set(sessionId, session); + let socketSessions = this.sessionsBySocket.get(socketId); + + if (!socketSessions) { + socketSessions = new Set(); + this.sessionsBySocket.set(socketId, socketSessions); + } + + socketSessions.add(sessionId); + createdTarget = undefined; + + await this.sendCommand(session, 'Page.enable'); + await this.sendCommand(session, 'Emulation.setDeviceMetricsOverride', { + width: PREVIEW_VIEWPORT_WIDTH, + height: PREVIEW_VIEWPORT_HEIGHT, + deviceScaleFactor: 1, + mobile: false, + }); + await this.sendCommand(session, 'Page.bringToFront').catch(() => undefined); + await this.sendCommand(session, 'Page.startScreencast', { + format: 'jpeg', + quality: SCREENCAST_QUALITY, + everyNthFrame: SCREENCAST_EVERY_NTH, + maxWidth: SCREENCAST_MAX_WIDTH, + maxHeight: SCREENCAST_MAX_HEIGHT, + }); + await this.emitLocation(session); + } catch (error) { + if (createdTarget && containerIp) { + await this.closeCdpTargetHttp(containerIp, createdTarget.id); + } + + if (joinedNetwork && networkId && managerContainerId) { + await this.dockerService.disconnectContainerFromNetwork(managerContainerId, networkId); + } + + throw error; + } + } + + async stopSession(sessionId: string): Promise { + await this.cleanupSession(sessionId, true); + } + + async stopSessionsForSocket(socketId: string): Promise { + const sessionIds = this.sessionsBySocket.get(socketId); + + if (!sessionIds) { + return; + } + + for (const sessionId of [...sessionIds]) { + await this.cleanupSession(sessionId, true); + } + } + + hasSession(sessionId: string, socketId: string): boolean { + const session = this.sessionsById.get(sessionId); + + return !!session && session.socketId === socketId; + } + + async dispatchInput(sessionId: string, input: BrowserPreviewInput): Promise { + const session = this.sessionsById.get(sessionId); + + if (!session) { + throw new Error('Browser preview session not found'); + } + + if (input.kind === 'mouse') { + const event = this.normalizeMouseInput(input.event); + + await this.sendCommand(session, 'Input.dispatchMouseEvent', event); + + return; + } + + const event = this.normalizeKeyInput(input.event); + + await this.sendCommand(session, 'Input.dispatchKeyEvent', event); + } + + async dispatchCommand(sessionId: string, command: BrowserPreviewCommand): Promise { + const session = this.sessionsById.get(sessionId); + + if (!session) { + throw new Error('Browser preview session not found'); + } + + switch (command.type) { + case 'navigate': { + const url = this.normalizeNavigateUrl(command.url); + + if (!url) { + throw new Error('Invalid navigation URL'); + } + + await this.sendCommand(session, 'Page.navigate', { url }); + break; + } + case 'reload': + await this.sendCommand(session, 'Page.reload', {}); + break; + case 'back': + case 'forward': { + const history = (await this.sendCommand(session, 'Page.getNavigationHistory', {})) as { + currentIndex?: number; + entries?: Array<{ id: number; url?: string }>; + }; + const currentIndex = typeof history.currentIndex === 'number' ? history.currentIndex : -1; + const entries = Array.isArray(history.entries) ? history.entries : []; + const nextIndex = command.type === 'back' ? currentIndex - 1 : currentIndex + 1; + const entry = entries[nextIndex]; + + if (!entry || typeof entry.id !== 'number') { + return; + } + + await this.sendCommand(session, 'Page.navigateToHistoryEntry', { entryId: entry.id }); + break; + } + default: + throw new Error('Unsupported browser preview command'); + } + + await this.emitLocation(session); + } + + private normalizeNavigateUrl(raw: string): string | null { + const trimmed = typeof raw === 'string' ? raw.trim() : ''; + + if (!trimmed || trimmed.length > 2048) { + return null; + } + + try { + const url = new URL(trimmed); + + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return null; + } + + return url.toString(); + } catch { + return null; + } + } + + private async emitLocation(session: CdpSession): Promise { + try { + const history = (await this.sendCommand(session, 'Page.getNavigationHistory', {})) as { + currentIndex?: number; + entries?: Array<{ id: number; url?: string }>; + }; + const currentIndex = typeof history.currentIndex === 'number' ? history.currentIndex : -1; + const entries = Array.isArray(history.entries) ? history.entries : []; + const current = entries[currentIndex]; + const url = typeof current?.url === 'string' ? current.url : 'about:blank'; + + session.onLocation({ + sessionId: session.sessionId, + url, + canGoBack: currentIndex > 0, + canGoForward: currentIndex >= 0 && currentIndex < entries.length - 1, + }); + } catch (error) { + const err = error as { message?: string }; + + this.logger.warn(`Failed to read browser preview location for ${session.sessionId}: ${err.message}`); + } + } + + private normalizeMouseInput(event: BrowserPreviewMouseInput): BrowserPreviewMouseInput { + const allowedTypes = new Set(['mousePressed', 'mouseReleased', 'mouseMoved', 'mouseWheel']); + + if (!allowedTypes.has(event.type)) { + throw new Error('Invalid mouse event type'); + } + + if (!Number.isFinite(event.x) || !Number.isFinite(event.y)) { + throw new Error('Invalid mouse coordinates'); + } + + const allowedButtons = new Set(['none', 'left', 'middle', 'right']); + const button = event.button && allowedButtons.has(event.button) ? event.button : 'none'; + + return { + type: event.type, + x: Math.max(0, Math.min(event.x, 10000)), + y: Math.max(0, Math.min(event.y, 10000)), + button, + buttons: typeof event.buttons === 'number' ? event.buttons & 0xff : 0, + clickCount: typeof event.clickCount === 'number' ? Math.min(Math.max(event.clickCount, 1), 3) : 1, + deltaX: typeof event.deltaX === 'number' ? event.deltaX : undefined, + deltaY: typeof event.deltaY === 'number' ? event.deltaY : undefined, + modifiers: typeof event.modifiers === 'number' ? event.modifiers & 0xff : 0, + }; + } + + private normalizeKeyInput(event: BrowserPreviewKeyInput): BrowserPreviewKeyInput { + const allowedTypes = new Set(['keyDown', 'keyUp', 'rawKeyDown', 'char']); + + if (!allowedTypes.has(event.type)) { + throw new Error('Invalid key event type'); + } + + const text = event.text?.slice(0, 32); + const unmodifiedText = event.unmodifiedText?.slice(0, 32); + const key = event.key?.slice(0, 64); + const code = event.code?.slice(0, 64); + + return { + type: event.type, + text, + unmodifiedText, + key, + code, + windowsVirtualKeyCode: + typeof event.windowsVirtualKeyCode === 'number' ? event.windowsVirtualKeyCode & 0xffff : undefined, + nativeVirtualKeyCode: + typeof event.nativeVirtualKeyCode === 'number' ? event.nativeVirtualKeyCode & 0xffff : undefined, + modifiers: typeof event.modifiers === 'number' ? event.modifiers & 0xff : 0, + }; + } + + private handleMessage(session: CdpSession, raw: unknown): void { + let message: { + id?: number; + result?: unknown; + error?: { message?: string }; + method?: string; + params?: { + data?: string; + sessionId?: number; + metadata?: BrowserPreviewFrame['metadata']; + }; + }; + + try { + const text = typeof raw === 'string' ? raw : Buffer.isBuffer(raw) ? raw.toString('utf8') : String(raw); + + message = JSON.parse(text) as typeof message; + } catch { + return; + } + + if (typeof message.id === 'number') { + const pending = session.pending.get(message.id); + + if (pending) { + session.pending.delete(message.id); + + if (message.error) { + pending.reject(new Error(message.error.message || 'CDP command failed')); + } else { + pending.resolve(message.result); + } + } + + return; + } + + if (message.method === 'Page.screencastFrame' && message.params?.data && message.params.metadata) { + const data = message.params.data; + + if (data.length > MAX_FRAME_BYTES) { + this.logger.warn(`Dropping oversized browser preview frame for session ${session.sessionId}`); + } else { + session.onFrame({ + sessionId: session.sessionId, + data, + metadata: message.params.metadata, + }); + } + + const ackSessionId = message.params.sessionId; + + if (typeof ackSessionId === 'number') { + void this.sendCommand(session, 'Page.screencastFrameAck', { sessionId: ackSessionId }).catch(() => undefined); + } + + return; + } + + if (message.method === 'Page.frameNavigated') { + const frame = (message as { params?: { frame?: { parentId?: string; url?: string } } }).params?.frame; + + // Only react to top-level navigations. + if (frame && !frame.parentId) { + void this.emitLocation(session); + } + } + } + + private async sendCommand(session: CdpSession, method: string, params?: Record): Promise { + if (session.ws.readyState !== WebSocket.OPEN) { + throw new Error('Browser preview session is not connected'); + } + + const id = session.nextCommandId++; + + return await new Promise((resolve, reject) => { + session.pending.set(id, { resolve, reject }); + session.ws.send(JSON.stringify({ id, method, params })); + }); + } + + private async cleanupSession(sessionId: string, closeWs: boolean): Promise { + const session = this.sessionsById.get(sessionId); + + if (!session) { + return; + } + + this.sessionsById.delete(sessionId); + const socketSessions = this.sessionsBySocket.get(session.socketId); + + if (socketSessions) { + socketSessions.delete(sessionId); + + if (socketSessions.size === 0) { + this.sessionsBySocket.delete(session.socketId); + } + } + + if (session.ownsTarget && session.ws.readyState === WebSocket.OPEN) { + try { + await Promise.race([ + (async () => { + await this.sendCommand(session, 'Page.stopScreencast', {}).catch(() => undefined); + await this.sendCommand(session, 'Page.close', {}); + })(), + new Promise((resolve) => setTimeout(resolve, 2000)), + ]); + } catch { + // Fall through to HTTP close. + } + } + + if (session.ownsTarget) { + await this.closeCdpTargetHttp(session.containerIp, session.cdpTargetId); + } + + if (closeWs && session.ws.readyState === WebSocket.OPEN) { + try { + session.ws.close(); + } catch { + // ignore + } + } + + for (const [, pending] of session.pending) { + pending.reject(new Error('Browser preview session closed')); + } + + session.pending.clear(); + + if (session.joinedNetwork && session.networkId && session.managerContainerId) { + await this.dockerService.disconnectContainerFromNetwork(session.managerContainerId, session.networkId); + } + + session.onClosed(); + } + + private async waitForCdpReady(vncContainerId: string, networkId?: string): Promise { + const deadline = Date.now() + CDP_READY_TIMEOUT_MS; + let lastError: unknown; + + while (Date.now() < deadline) { + try { + const ip = await this.dockerService.getContainerIpAddress(vncContainerId, networkId); + const version = await this.fetchJson<{ webSocketDebuggerUrl?: string }>( + `http://${ip}:${CDP_PORT}/json/version`, + ); + + if (version?.webSocketDebuggerUrl) { + return ip; + } + } catch (error) { + lastError = error; + } + + await new Promise((resolve) => setTimeout(resolve, CDP_READY_POLL_MS)); + } + + this.logger.error(`Timed out waiting for Chromium CDP on container ${vncContainerId}`); + throw lastError instanceof Error ? lastError : new Error('Chromium CDP is not available'); + } + + /** + * Open a dedicated about:blank tab for this Preview session (do not reuse the desktop window). + */ + private async createFreshPageTarget(ip: string): Promise { + const newUrl = `http://${ip}:${CDP_PORT}/json/new?${encodeURIComponent('about:blank')}`; + + try { + const created = await this.fetchJson(newUrl, { method: 'PUT' }); + + if (created?.id && created.webSocketDebuggerUrl) { + return created; + } + } catch { + // Older Chromium builds only accept GET /json/new. + } + + const createdGet = await this.fetchJson(newUrl, { method: 'GET' }); + + if (createdGet?.id && createdGet.webSocketDebuggerUrl) { + return createdGet; + } + + throw new Error('Failed to create a fresh Chromium page for browser preview'); + } + + private async closeCdpTargetHttp(ip: string, targetId: string): Promise { + const safeId = encodeURIComponent(targetId); + const url = `http://${ip}:${CDP_PORT}/json/close/${safeId}`; + + try { + await this.fetchJson(url, { method: 'PUT' }); + } catch { + try { + await this.fetchJson(url, { method: 'GET' }); + } catch (error) { + const err = error as { message?: string }; + + this.logger.warn(`Failed to close CDP target ${targetId}: ${err.message}`); + } + } + } + + private async fetchJson(url: string, init?: RequestInit): Promise { + const response = await fetch(url, { + ...init, + signal: AbortSignal.timeout(5000), + }); + + if (!response.ok) { + throw new Error(`CDP HTTP ${response.status}`); + } + + const text = await response.text(); + + if (!text) { + return {} as T; + } + + return JSON.parse(text) as T; + } + + private rewriteCdpWebSocketUrl(debuggerUrl: string, containerIp: string): string { + const rewritten = new URL(debuggerUrl); + + rewritten.hostname = containerIp; + rewritten.port = String(CDP_PORT); + + return rewritten.toString(); + } + + private openWebSocket(url: string): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url); + const onOpen = () => { + cleanup(); + resolve(ws); + }; + const onError = () => { + cleanup(); + reject(new Error('Failed to open CDP WebSocket')); + }; + const cleanup = () => { + ws.off('open', onOpen); + ws.off('error', onError); + }; + + ws.on('open', onOpen); + ws.on('error', onError); + }); + } +} diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/docker.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/docker.service.spec.ts index 0aa759d74..44254c623 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/docker.service.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/docker.service.spec.ts @@ -153,7 +153,7 @@ describe('DockerService', () => { }); }); - it('should pull image, create and start container with binds and ports', async () => { + it('should use local image without pulling when inspect succeeds', async () => { const result = await service.createContainer({ image: 'node:22-alpine', env: { FOO: 'bar' }, @@ -168,7 +168,9 @@ describe('DockerService', () => { network: 'test-network', }); - expect((mockDocker as any).pull).toHaveBeenCalledWith('node:22-alpine', expect.any(Function)); + expect(mockDocker.getImage).toHaveBeenCalledWith('node:22-alpine'); + expect(mockImage.inspect).toHaveBeenCalled(); + expect((mockDocker as any).pull).not.toHaveBeenCalled(); expect((mockDocker as any).createContainer).toHaveBeenCalledWith({ Image: 'node:22-alpine', Env: ['FOO=bar'], @@ -202,21 +204,23 @@ describe('DockerService', () => { try { const result = await service.createContainer({ volumes: [], ports: [] }); - expect((mockDocker as any).pull).toHaveBeenCalledWith('env/image:latest', expect.any(Function)); + expect(mockDocker.getImage).toHaveBeenCalledWith('env/image:latest'); + expect((mockDocker as any).pull).not.toHaveBeenCalled(); expect(result).toBe('abc123'); } finally { process.env.AGENT_DEFAULT_IMAGE = original; } }); - it('should proceed if pulling image fails (image exists locally)', async () => { + it('should not pull when the image already exists locally', async () => { (mockDocker as any).pull = jest.fn((_image: string, cb: (err: unknown, stream?: any) => void) => { - cb(new Error('pull failed')); + cb(new Error('pull should not be called')); }); (mockDocker as any).createContainer = jest.fn().mockResolvedValue(createdContainer); const result = await service.createContainer({ image: 'local/image:tag' }); + expect((mockDocker as any).pull).not.toHaveBeenCalled(); expect((mockDocker as any).createContainer).toHaveBeenCalled(); expect(result).toBe('abc123'); }); @@ -2534,19 +2538,36 @@ describe('DockerService', () => { it('should pull when image inspect returns 404', async () => { mockImage.inspect.mockRejectedValue({ statusCode: 404 }); + (mockDocker as any).modem = { + followProgress: (_s: any, done: (err?: unknown) => void) => done(), + }; + (mockDocker as any).pull = jest.fn((_image: string, cb: (err: unknown, stream: any) => void) => { + cb(null, {}); + }); await service.ensureImageExists('missing:image'); expect(mockDocker.getImage).toHaveBeenCalledWith('missing:image'); - expect((mockDocker as any).pull).toHaveBeenCalledWith('missing:image'); + expect((mockDocker as any).pull).toHaveBeenCalledWith('missing:image', expect.any(Function)); }); - it('should not pull when image inspect fails with a non-404 error', async () => { + it('should throw when image inspect fails with a non-404 error', async () => { mockImage.inspect.mockRejectedValue({ statusCode: 500, message: 'server error' }); - await service.ensureImageExists('node:22-alpine'); - + await expect(service.ensureImageExists('node:22-alpine')).rejects.toEqual({ + statusCode: 500, + message: 'server error', + }); expect((mockDocker as any).pull).not.toHaveBeenCalled(); }); + + it('should throw NotFoundException when pull fails after a local 404', async () => { + mockImage.inspect.mockRejectedValue({ statusCode: 404 }); + (mockDocker as any).pull = jest.fn((_image: string, cb: (err: unknown, stream?: any) => void) => { + cb(new Error('no such host')); + }); + + await expect(service.ensureImageExists('missing:image')).rejects.toBeInstanceOf(NotFoundException); + }); }); }); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/docker.service.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/docker.service.ts index f5da75c91..334923128 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/docker.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/docker.service.ts @@ -77,21 +77,6 @@ export class DockerService { portBindings[key].push({ HostPort: p.hostPort ? String(p.hostPort) : undefined }); } - // Ensure image is available (pull if necessary) - await new Promise((resolve, reject) => { - this.docker.pull(resolvedImage, (err: unknown, stream: NodeJS.ReadableStream) => { - if (err) return reject(err); - - // followProgress is available via modem (not typed in dockerode) - const modem: any = (this.docker as any).modem; - - modem.followProgress(stream, (pullErr: unknown) => (pullErr ? reject(pullErr) : resolve())); - }); - }).catch((e) => { - // If pull fails, log and proceed - create might still work if image exists locally - this.logger.warn(`Failed to pull image ${resolvedImage}: ${(e as Error).message}`); - }); - // Map env object to KEY=VALUE strings as required by Docker API // Escape special characters in the value to preserve intent (no quoting) const escapeEnvValue = (val: string): string => @@ -115,7 +100,7 @@ export class DockerService { }) : undefined; - // Ensure the Docker image exists + // Prefer a local image; pull only when inspect reports it is missing. await this.ensureImageExists(resolvedImage); // Create container @@ -720,6 +705,112 @@ export class DockerService { } } + /** + * Connect a container to an existing Docker network. + */ + async connectContainerToNetwork(containerId: string, networkId: string): Promise { + try { + const network = this.docker.getNetwork(networkId); + + await network.connect({ Container: containerId }); + this.logger.debug(`Connected container ${containerId} to network ${networkId}`); + } catch (error: unknown) { + const err = error as { statusCode?: number; message?: string; stack?: string }; + + // Already connected is fine + if (err.message?.includes('already exists') || err.message?.includes('already connected')) { + return; + } + + this.logger.error(`Error connecting container ${containerId} to network ${networkId}: ${err.message}`, err.stack); + throw error; + } + } + + /** + * Disconnect a container from a Docker network. + */ + async disconnectContainerFromNetwork(containerId: string, networkId: string): Promise { + try { + const network = this.docker.getNetwork(networkId); + + await network.disconnect({ Container: containerId, Force: true }); + this.logger.debug(`Disconnected container ${containerId} from network ${networkId}`); + } catch (error: unknown) { + const err = error as { statusCode?: number; message?: string }; + + if (err.statusCode === 404) { + return; + } + + this.logger.warn( + `Failed to disconnect container ${containerId} from network ${networkId}: ${err.message ?? 'unknown error'}`, + ); + } + } + + /** + * Resolve a Docker container name (without leading slash) for DNS on shared networks. + */ + async getContainerName(containerId: string): Promise { + const container = this.docker.getContainer(containerId); + const inspectInfo = await container.inspect(); + const name = inspectInfo.Name?.startsWith('/') ? inspectInfo.Name.slice(1) : inspectInfo.Name; + + if (!name) { + throw new NotFoundException(`No name found for container '${containerId}'`); + } + + return name; + } + + /** + * Resolve a container IPv4 address on a specific network (or the first attached network). + */ + async getContainerIpAddress(containerId: string, networkId?: string): Promise { + const container = this.docker.getContainer(containerId); + const inspectInfo = await container.inspect(); + const networks = inspectInfo.NetworkSettings?.Networks || {}; + + if (networkId) { + const named = networks[networkId]; + + if (named?.IPAddress) { + return named.IPAddress; + } + + for (const entry of Object.values(networks)) { + if (entry?.NetworkID === networkId && entry.IPAddress) { + return entry.IPAddress; + } + } + } + + for (const entry of Object.values(networks)) { + if (entry?.IPAddress) { + return entry.IPAddress; + } + } + + throw new NotFoundException(`No IP address found for container '${containerId}'`); + } + + /** + * Resolve the manager API container ID for joining per-agent Docker networks. + * Prefer MANAGER_CONTAINER_ID; fall back to Docker HOSTNAME (short container id). + */ + getManagerContainerId(): string | undefined { + const configured = process.env.MANAGER_CONTAINER_ID?.trim(); + + if (configured) { + return configured; + } + + const hostname = process.env.HOSTNAME?.trim(); + + return hostname || undefined; + } + /** * Get container logs as a stream of lines. * First returns historical logs, then tails live logs. @@ -2006,19 +2097,45 @@ export class DockerService { } /** - * Ensure a Docker image exists. + * Ensure a Docker image exists locally, pulling only when inspect returns 404. * @param image - The image name (including tag) - * @throws NotFoundException if image is not found + * @throws NotFoundException if the image is missing locally and cannot be pulled */ async ensureImageExists(image: string): Promise { try { await this.docker.getImage(image).inspect(); + + return; } catch (error: unknown) { const err = error as { statusCode?: number }; - if (err.statusCode === 404) { - await this.docker.pull(image); + if (err.statusCode !== 404) { + throw error; } } + + this.logger.log(`Image ${image} not found locally; pulling...`); + + try { + await new Promise((resolve, reject) => { + this.docker.pull(image, (pullErr: unknown, stream: NodeJS.ReadableStream) => { + if (pullErr) { + reject(pullErr); + + return; + } + + // followProgress is available via modem (not typed in dockerode) + const modem: any = (this.docker as any).modem; + + modem.followProgress(stream, (progressErr: unknown) => (progressErr ? reject(progressErr) : resolve())); + }); + }); + } catch (pullError: unknown) { + const message = pullError instanceof Error ? pullError.message : String(pullError); + + this.logger.error(`Failed to pull image ${image}: ${message}`); + throw new NotFoundException(`Docker image '${image}' is not available locally and could not be pulled`); + } } } diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/index.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/index.ts index 07f381ffd..36c707c9e 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/index.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/index.ts @@ -119,6 +119,12 @@ export * from './lib/state/agents/agents.facade'; export * from './lib/state/agents/agents.reducer'; export * from './lib/state/agents/agents.selectors'; export * from './lib/state/agents/agents.types'; +export * from './lib/state/browser-preview/browser-preview.actions'; +export * from './lib/state/browser-preview/browser-preview.effects'; +export * from './lib/state/browser-preview/browser-preview.facade'; +export * from './lib/state/browser-preview/browser-preview.reducer'; +export * from './lib/state/browser-preview/browser-preview.selectors'; +export * from './lib/state/browser-preview/browser-preview.utils'; export * from './lib/state/client-agent-autonomy/client-agent-autonomy.actions'; export * from './lib/state/client-agent-autonomy/client-agent-autonomy.effects'; export * from './lib/state/client-agent-autonomy/client-agent-autonomy.facade'; diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/agents/agents.types.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/agents/agents.types.ts index 3a9da4276..569b6028c 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/agents/agents.types.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/agents/agents.types.ts @@ -14,6 +14,9 @@ export interface AgentResponseDto { agentType: string; containerType: ContainerType; capabilities?: AgentTypeCapabilities; + browserPreview?: { + enabled: true; + }; vnc?: { port: number; password: string; @@ -37,6 +40,7 @@ export interface CreateAgentDto { containerType?: ContainerType; gitRepositorySetupMode?: 'clone' | 'empty'; gitRepositoryUrl?: string; + createBrowserPreview?: boolean; createVirtualWorkspace?: boolean; createSshConnection?: boolean; } diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/browser-preview/browser-preview.actions.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/browser-preview/browser-preview.actions.ts new file mode 100644 index 000000000..d174cd83d --- /dev/null +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/browser-preview/browser-preview.actions.ts @@ -0,0 +1,47 @@ +import { createAction, props } from '@ngrx/store'; + +export const openBrowserPreview = createAction( + '[Browser Preview] Open', + props<{ agentId: string; sessionId: string }>(), +); + +export const browserPreviewStarted = createAction( + '[Browser Preview] Started', + props<{ sessionId: string; workspaceHostname?: string }>(), +); + +export const browserPreviewFrameReceived = createAction( + '[Browser Preview] Frame Received', + props<{ + sessionId: string; + data: string; + metadata: { + offsetTop: number; + pageScaleFactor: number; + deviceWidth: number; + deviceHeight: number; + scrollOffsetX: number; + scrollOffsetY: number; + timestamp: number; + }; + }>(), +); + +export const browserPreviewLocationReceived = createAction( + '[Browser Preview] Location Received', + props<{ sessionId: string; url: string; canGoBack: boolean; canGoForward: boolean }>(), +); + +export const stopBrowserPreview = createAction( + '[Browser Preview] Stop', + props<{ sessionId: string; agentId: string }>(), +); + +export const browserPreviewStopped = createAction('[Browser Preview] Stopped', props<{ sessionId: string }>()); + +export const browserPreviewError = createAction('[Browser Preview] Error', props<{ error: string }>()); + +export const closeBrowserPreviewUi = createAction( + '[Browser Preview] Close UI', + props<{ sessionId: string | null; agentId: string | null }>(), +); diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/browser-preview/browser-preview.effects.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/browser-preview/browser-preview.effects.ts new file mode 100644 index 000000000..7e60bd057 --- /dev/null +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/browser-preview/browser-preview.effects.ts @@ -0,0 +1,151 @@ +import { inject } from '@angular/core'; +import { Actions, createEffect, ofType } from '@ngrx/effects'; +import { filter, map, tap } from 'rxjs/operators'; + +import { forwardedEventReceived } from '../sockets/sockets.actions'; +import { SocketsFacade } from '../sockets/sockets.facade'; +import type { + BrowserPreviewFrameData, + BrowserPreviewLocationData, + BrowserPreviewStartedData, + BrowserPreviewStoppedData, + SuccessResponse, +} from '../sockets/sockets.types'; +import { + browserPreviewError, + browserPreviewFrameReceived, + browserPreviewLocationReceived, + browserPreviewStarted, + browserPreviewStopped, + closeBrowserPreviewUi, + openBrowserPreview, + stopBrowserPreview, +} from './browser-preview.actions'; + +export const startBrowserPreview$ = createEffect( + (actions$ = inject(Actions), socketsFacade = inject(SocketsFacade)) => + actions$.pipe( + ofType(openBrowserPreview), + tap(({ agentId, sessionId }) => { + socketsFacade.forwardStartBrowserPreview(sessionId, agentId); + }), + ), + { functional: true, dispatch: false }, +); + +export const stopBrowserPreview$ = createEffect( + (actions$ = inject(Actions), socketsFacade = inject(SocketsFacade)) => + actions$.pipe( + ofType(stopBrowserPreview), + tap(({ sessionId, agentId }) => { + socketsFacade.forwardStopBrowserPreview(sessionId, agentId); + }), + ), + { functional: true, dispatch: false }, +); + +export const closeBrowserPreviewUiStopsSession$ = createEffect( + (actions$ = inject(Actions), socketsFacade = inject(SocketsFacade)) => + actions$.pipe( + ofType(closeBrowserPreviewUi), + filter(({ sessionId, agentId }) => !!sessionId && !!agentId), + tap(({ sessionId, agentId }) => { + socketsFacade.forwardStopBrowserPreview(sessionId as string, agentId as string); + }), + ), + { functional: true, dispatch: false }, +); + +export const onBrowserPreviewStarted$ = createEffect( + (actions$ = inject(Actions)) => + actions$.pipe( + ofType(forwardedEventReceived), + filter(({ event }) => event === 'browserPreviewStarted'), + map(({ payload }) => { + const data = (payload as SuccessResponse).data; + + return browserPreviewStarted({ + sessionId: data.sessionId, + workspaceHostname: data.workspaceHostname, + }); + }), + ), + { functional: true }, +); + +export const onBrowserPreviewFrame$ = createEffect( + (actions$ = inject(Actions)) => + actions$.pipe( + ofType(forwardedEventReceived), + filter(({ event }) => event === 'browserPreviewFrame'), + map(({ payload }) => { + const data = (payload as SuccessResponse).data; + + return browserPreviewFrameReceived({ + sessionId: data.sessionId, + data: data.data, + metadata: data.metadata, + }); + }), + ), + { functional: true }, +); + +export const onBrowserPreviewStopped$ = createEffect( + (actions$ = inject(Actions)) => + actions$.pipe( + ofType(forwardedEventReceived), + filter(({ event }) => event === 'browserPreviewStopped'), + map(({ payload }) => { + const data = (payload as SuccessResponse).data; + + return browserPreviewStopped({ sessionId: data.sessionId }); + }), + ), + { functional: true }, +); + +export const onBrowserPreviewLocation$ = createEffect( + (actions$ = inject(Actions)) => + actions$.pipe( + ofType(forwardedEventReceived), + filter(({ event }) => event === 'browserPreviewLocation'), + map(({ payload }) => { + const data = (payload as SuccessResponse).data; + + return browserPreviewLocationReceived({ + sessionId: data.sessionId, + url: data.url, + canGoBack: data.canGoBack, + canGoForward: data.canGoForward, + }); + }), + ), + { functional: true }, +); + +export const onBrowserPreviewSocketError$ = createEffect( + (actions$ = inject(Actions)) => + actions$.pipe( + ofType(forwardedEventReceived), + filter(({ event, payload }) => { + if (event !== 'error') { + return false; + } + + const errorPayload = payload as { error?: { code?: string; message?: string } }; + + return ( + errorPayload.error?.code === 'PREVIEW_DISABLED' || + errorPayload.error?.code === 'PREVIEW_ERROR' || + errorPayload.error?.code === 'UNAUTHORIZED' + ); + }), + map(({ payload }) => { + const errorPayload = payload as { error?: { message?: string } }; + + return browserPreviewError({ error: errorPayload.error?.message || 'Browser preview error' }); + }), + ), + { functional: true }, +); diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/browser-preview/browser-preview.facade.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/browser-preview/browser-preview.facade.ts new file mode 100644 index 000000000..77d41b453 --- /dev/null +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/browser-preview/browser-preview.facade.ts @@ -0,0 +1,91 @@ +import { inject, Injectable } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { take } from 'rxjs/operators'; + +import { SocketsFacade } from '../sockets/sockets.facade'; +import { closeBrowserPreviewUi, openBrowserPreview, stopBrowserPreview } from './browser-preview.actions'; +import { + selectBrowserPreviewAgentId, + selectBrowserPreviewCanGoBack, + selectBrowserPreviewCanGoForward, + selectBrowserPreviewCurrentUrl, + selectBrowserPreviewError, + selectBrowserPreviewFrameData, + selectBrowserPreviewFrameMetadata, + selectBrowserPreviewOpen, + selectBrowserPreviewSessionId, + selectBrowserPreviewStarting, + selectBrowserPreviewWorkspaceHostname, +} from './browser-preview.selectors'; + +@Injectable() +export class BrowserPreviewFacade { + private readonly store = inject(Store); + private readonly socketsFacade = inject(SocketsFacade); + + readonly open$ = this.store.select(selectBrowserPreviewOpen); + readonly starting$ = this.store.select(selectBrowserPreviewStarting); + readonly sessionId$ = this.store.select(selectBrowserPreviewSessionId); + readonly agentId$ = this.store.select(selectBrowserPreviewAgentId); + readonly frameData$ = this.store.select(selectBrowserPreviewFrameData); + readonly frameMetadata$ = this.store.select(selectBrowserPreviewFrameMetadata); + readonly currentUrl$ = this.store.select(selectBrowserPreviewCurrentUrl); + readonly workspaceHostname$ = this.store.select(selectBrowserPreviewWorkspaceHostname); + readonly canGoBack$ = this.store.select(selectBrowserPreviewCanGoBack); + readonly canGoForward$ = this.store.select(selectBrowserPreviewCanGoForward); + readonly error$ = this.store.select(selectBrowserPreviewError); + + openPreview(agentId: string): void { + const sessionId = `preview-${agentId}-${Date.now()}`; + + this.store.dispatch(openBrowserPreview({ agentId, sessionId })); + } + + stopPreview(sessionId: string, agentId: string): void { + this.store.dispatch(stopBrowserPreview({ sessionId, agentId })); + } + + closePreview(): void { + let sessionId: string | null = null; + let agentId: string | null = null; + + this.store + .select(selectBrowserPreviewSessionId) + .pipe(take(1)) + .subscribe((value) => { + sessionId = value; + }); + this.store + .select(selectBrowserPreviewAgentId) + .pipe(take(1)) + .subscribe((value) => { + agentId = value; + }); + + this.store.dispatch(closeBrowserPreviewUi({ sessionId, agentId })); + } + + sendMouseInput(sessionId: string, agentId: string, event: Record): void { + this.socketsFacade.forwardBrowserPreviewInput(sessionId, 'mouse', event, agentId); + } + + sendKeyInput(sessionId: string, agentId: string, event: Record): void { + this.socketsFacade.forwardBrowserPreviewInput(sessionId, 'key', event, agentId); + } + + navigate(sessionId: string, agentId: string, url: string): void { + this.socketsFacade.forwardBrowserPreviewCommand(sessionId, 'navigate', agentId, url); + } + + reload(sessionId: string, agentId: string): void { + this.socketsFacade.forwardBrowserPreviewCommand(sessionId, 'reload', agentId); + } + + goBack(sessionId: string, agentId: string): void { + this.socketsFacade.forwardBrowserPreviewCommand(sessionId, 'back', agentId); + } + + goForward(sessionId: string, agentId: string): void { + this.socketsFacade.forwardBrowserPreviewCommand(sessionId, 'forward', agentId); + } +} diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/browser-preview/browser-preview.reducer.spec.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/browser-preview/browser-preview.reducer.spec.ts new file mode 100644 index 000000000..62e19faf4 --- /dev/null +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/browser-preview/browser-preview.reducer.spec.ts @@ -0,0 +1,128 @@ +import { + browserPreviewError, + browserPreviewFrameReceived, + browserPreviewLocationReceived, + browserPreviewStarted, + browserPreviewStopped, + closeBrowserPreviewUi, + openBrowserPreview, + stopBrowserPreview, +} from './browser-preview.actions'; +import { browserPreviewReducer, initialBrowserPreviewState } from './browser-preview.reducer'; + +describe('browserPreviewReducer', () => { + it('should open preview and mark starting', () => { + const state = browserPreviewReducer( + initialBrowserPreviewState, + openBrowserPreview({ agentId: 'a1', sessionId: 's1' }), + ); + + expect(state.open).toBe(true); + expect(state.starting).toBe(true); + expect(state.agentId).toBe('a1'); + expect(state.sessionId).toBe('s1'); + }); + + it('should store frames for the active session', () => { + const opened = browserPreviewReducer( + initialBrowserPreviewState, + openBrowserPreview({ agentId: 'a1', sessionId: 's1' }), + ); + const framed = browserPreviewReducer( + opened, + browserPreviewFrameReceived({ + sessionId: 's1', + data: 'abc', + metadata: { + offsetTop: 0, + pageScaleFactor: 1, + deviceWidth: 800, + deviceHeight: 600, + scrollOffsetX: 0, + scrollOffsetY: 0, + timestamp: 1, + }, + }), + ); + + expect(framed.latestFrameData).toBe('abc'); + expect(framed.starting).toBe(false); + expect(framed.latestFrameMetadata?.deviceWidth).toBe(800); + }); + + it('should ignore frames for other sessions', () => { + const opened = browserPreviewReducer( + initialBrowserPreviewState, + openBrowserPreview({ agentId: 'a1', sessionId: 's1' }), + ); + const framed = browserPreviewReducer( + opened, + browserPreviewFrameReceived({ + sessionId: 'other', + data: 'abc', + metadata: { + offsetTop: 0, + pageScaleFactor: 1, + deviceWidth: 800, + deviceHeight: 600, + scrollOffsetX: 0, + scrollOffsetY: 0, + timestamp: 1, + }, + }), + ); + + expect(framed.latestFrameData).toBeNull(); + }); + + it('should store location for the active session', () => { + const opened = browserPreviewReducer( + initialBrowserPreviewState, + openBrowserPreview({ agentId: 'a1', sessionId: 's1' }), + ); + const located = browserPreviewReducer( + opened, + browserPreviewLocationReceived({ + sessionId: 's1', + url: 'https://example.com/', + canGoBack: true, + canGoForward: false, + }), + ); + + expect(located.currentUrl).toBe('https://example.com/'); + expect(located.canGoBack).toBe(true); + expect(located.canGoForward).toBe(false); + }); + + it('should reset on stopped and close', () => { + const opened = browserPreviewReducer( + initialBrowserPreviewState, + openBrowserPreview({ agentId: 'a1', sessionId: 's1' }), + ); + const started = browserPreviewReducer(opened, browserPreviewStarted({ sessionId: 's1' })); + const stopped = browserPreviewReducer(started, browserPreviewStopped({ sessionId: 's1' })); + + expect(stopped).toEqual(initialBrowserPreviewState); + + const reopened = browserPreviewReducer( + initialBrowserPreviewState, + openBrowserPreview({ agentId: 'a1', sessionId: 's2' }), + ); + const closed = browserPreviewReducer(reopened, closeBrowserPreviewUi({ sessionId: 's2', agentId: 'a1' })); + + expect(closed).toEqual(initialBrowserPreviewState); + }); + + it('should keep session while stopping and record errors', () => { + const opened = browserPreviewReducer( + initialBrowserPreviewState, + openBrowserPreview({ agentId: 'a1', sessionId: 's1' }), + ); + const stopping = browserPreviewReducer(opened, stopBrowserPreview({ sessionId: 's1', agentId: 'a1' })); + const errored = browserPreviewReducer(stopping, browserPreviewError({ error: 'fail' })); + + expect(stopping.starting).toBe(false); + expect(errored.error).toBe('fail'); + }); +}); diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/browser-preview/browser-preview.reducer.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/browser-preview/browser-preview.reducer.ts new file mode 100644 index 000000000..6f4612518 --- /dev/null +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/browser-preview/browser-preview.reducer.ts @@ -0,0 +1,128 @@ +import { createReducer, on } from '@ngrx/store'; + +import { + browserPreviewError, + browserPreviewFrameReceived, + browserPreviewLocationReceived, + browserPreviewStarted, + browserPreviewStopped, + closeBrowserPreviewUi, + openBrowserPreview, + stopBrowserPreview, +} from './browser-preview.actions'; + +export interface BrowserPreviewFrameMetadata { + offsetTop: number; + pageScaleFactor: number; + deviceWidth: number; + deviceHeight: number; + scrollOffsetX: number; + scrollOffsetY: number; + timestamp: number; +} + +export interface BrowserPreviewState { + open: boolean; + starting: boolean; + agentId: string | null; + sessionId: string | null; + workspaceHostname: string | null; + latestFrameData: string | null; + latestFrameMetadata: BrowserPreviewFrameMetadata | null; + currentUrl: string | null; + canGoBack: boolean; + canGoForward: boolean; + error: string | null; +} + +export const initialBrowserPreviewState: BrowserPreviewState = { + open: false, + starting: false, + agentId: null, + sessionId: null, + workspaceHostname: null, + latestFrameData: null, + latestFrameMetadata: null, + currentUrl: null, + canGoBack: false, + canGoForward: false, + error: null, +}; + +export const browserPreviewReducer = createReducer( + initialBrowserPreviewState, + on(openBrowserPreview, (state, { agentId, sessionId }) => ({ + ...state, + open: true, + starting: true, + agentId, + sessionId, + workspaceHostname: null, + latestFrameData: null, + latestFrameMetadata: null, + currentUrl: null, + canGoBack: false, + canGoForward: false, + error: null, + })), + on(browserPreviewStarted, (state, { sessionId, workspaceHostname }) => { + if (state.sessionId && state.sessionId !== sessionId) { + return state; + } + + return { + ...state, + starting: false, + sessionId, + workspaceHostname: workspaceHostname ?? state.workspaceHostname, + error: null, + }; + }), + on(browserPreviewFrameReceived, (state, { sessionId, data, metadata }) => { + if (state.sessionId && state.sessionId !== sessionId) { + return state; + } + + return { + ...state, + starting: false, + sessionId, + latestFrameData: data, + latestFrameMetadata: metadata, + }; + }), + on(browserPreviewLocationReceived, (state, { sessionId, url, canGoBack, canGoForward }) => { + if (state.sessionId && state.sessionId !== sessionId) { + return state; + } + + return { + ...state, + sessionId, + currentUrl: url, + canGoBack, + canGoForward, + }; + }), + on(stopBrowserPreview, (state) => ({ + ...state, + starting: false, + })), + on(browserPreviewStopped, (state, { sessionId }) => { + if (state.sessionId && state.sessionId !== sessionId) { + return state; + } + + return { + ...initialBrowserPreviewState, + }; + }), + on(closeBrowserPreviewUi, () => ({ + ...initialBrowserPreviewState, + })), + on(browserPreviewError, (state, { error }) => ({ + ...state, + starting: false, + error, + })), +); diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/browser-preview/browser-preview.selectors.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/browser-preview/browser-preview.selectors.ts new file mode 100644 index 000000000..8b376fed0 --- /dev/null +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/browser-preview/browser-preview.selectors.ts @@ -0,0 +1,39 @@ +import { createFeatureSelector, createSelector } from '@ngrx/store'; + +import { BrowserPreviewState } from './browser-preview.reducer'; + +export const selectBrowserPreviewState = createFeatureSelector('browserPreview'); + +export const selectBrowserPreviewOpen = createSelector(selectBrowserPreviewState, (state) => state.open); + +export const selectBrowserPreviewStarting = createSelector(selectBrowserPreviewState, (state) => state.starting); + +export const selectBrowserPreviewSessionId = createSelector(selectBrowserPreviewState, (state) => state.sessionId); + +export const selectBrowserPreviewAgentId = createSelector(selectBrowserPreviewState, (state) => state.agentId); + +export const selectBrowserPreviewFrameData = createSelector( + selectBrowserPreviewState, + (state) => state.latestFrameData, +); + +export const selectBrowserPreviewFrameMetadata = createSelector( + selectBrowserPreviewState, + (state) => state.latestFrameMetadata, +); + +export const selectBrowserPreviewCurrentUrl = createSelector(selectBrowserPreviewState, (state) => state.currentUrl); + +export const selectBrowserPreviewWorkspaceHostname = createSelector( + selectBrowserPreviewState, + (state) => state.workspaceHostname, +); + +export const selectBrowserPreviewCanGoBack = createSelector(selectBrowserPreviewState, (state) => state.canGoBack); + +export const selectBrowserPreviewCanGoForward = createSelector( + selectBrowserPreviewState, + (state) => state.canGoForward, +); + +export const selectBrowserPreviewError = createSelector(selectBrowserPreviewState, (state) => state.error); diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/browser-preview/browser-preview.utils.spec.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/browser-preview/browser-preview.utils.spec.ts new file mode 100644 index 000000000..f161008f9 --- /dev/null +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/browser-preview/browser-preview.utils.spec.ts @@ -0,0 +1,155 @@ +import { + isBrowserPreviewBlankUrl, + mapCanvasPointerToDeviceCoordinates, + mapDomKeyboardEventToCdpKeyEvents, + mapDomMouseButton, + normalizeBrowserPreviewNavigateUrl, +} from './browser-preview.utils'; + +describe('browser-preview.utils', () => { + describe('mapCanvasPointerToDeviceCoordinates', () => { + it('should map pointer to device coordinates', () => { + const result = mapCanvasPointerToDeviceCoordinates({ + clientX: 50, + clientY: 25, + canvasRect: { left: 0, top: 0, width: 100, height: 50 }, + deviceWidth: 800, + deviceHeight: 400, + }); + + expect(result).toEqual({ x: 400, y: 200 }); + }); + + it('should clamp coordinates to device bounds', () => { + const result = mapCanvasPointerToDeviceCoordinates({ + clientX: 200, + clientY: -10, + canvasRect: { left: 0, top: 0, width: 100, height: 50 }, + deviceWidth: 800, + deviceHeight: 400, + }); + + expect(result.x).toBe(800); + expect(result.y).toBe(0); + }); + }); + + describe('mapDomMouseButton', () => { + it('should map DOM buttons', () => { + expect(mapDomMouseButton(0)).toBe('left'); + expect(mapDomMouseButton(1)).toBe('middle'); + expect(mapDomMouseButton(2)).toBe('right'); + expect(mapDomMouseButton(9)).toBe('none'); + }); + }); + + describe('mapDomKeyboardEventToCdpKeyEvents', () => { + it('should emit rawKeyDown and char for Enter', () => { + const events = mapDomKeyboardEventToCdpKeyEvents( + { + key: 'Enter', + code: 'Enter', + keyCode: 13, + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + }, + 'keyDown', + ); + + expect(events).toEqual([ + expect.objectContaining({ type: 'rawKeyDown', key: 'Enter', windowsVirtualKeyCode: 13 }), + expect.objectContaining({ type: 'char', text: '\r', unmodifiedText: '\r' }), + ]); + }); + + it('should emit only keyUp on keyUp phase', () => { + const events = mapDomKeyboardEventToCdpKeyEvents( + { + key: 'Enter', + code: 'Enter', + keyCode: 13, + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + }, + 'keyUp', + ); + + expect(events).toEqual([expect.objectContaining({ type: 'keyUp', key: 'Enter' })]); + }); + + it('should emit char for printable characters', () => { + const events = mapDomKeyboardEventToCdpKeyEvents( + { + key: 'a', + code: 'KeyA', + keyCode: 65, + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + }, + 'keyDown', + ); + + expect(events[1]).toEqual(expect.objectContaining({ type: 'char', text: 'a' })); + }); + + it('should not emit char for Ctrl shortcuts', () => { + const events = mapDomKeyboardEventToCdpKeyEvents( + { + key: 'a', + code: 'KeyA', + keyCode: 65, + altKey: false, + ctrlKey: true, + metaKey: false, + shiftKey: false, + }, + 'keyDown', + ); + + expect(events).toHaveLength(1); + expect(events[0].type).toBe('rawKeyDown'); + }); + }); + + describe('normalizeBrowserPreviewNavigateUrl', () => { + it('should add https when scheme is missing', () => { + expect(normalizeBrowserPreviewNavigateUrl('example.com/path')).toBe('https://example.com/path'); + }); + + it('should accept http and https urls', () => { + expect(normalizeBrowserPreviewNavigateUrl('http://localhost:3000')).toBe('http://localhost:3000/'); + expect(normalizeBrowserPreviewNavigateUrl('https://example.com')).toBe('https://example.com/'); + }); + + it('should reject non-http schemes', () => { + expect(normalizeBrowserPreviewNavigateUrl('javascript:alert(1)')).toBeNull(); + expect(normalizeBrowserPreviewNavigateUrl('file:///etc/passwd')).toBeNull(); + }); + + it('should reject empty input', () => { + expect(normalizeBrowserPreviewNavigateUrl(' ')).toBeNull(); + }); + }); + + describe('isBrowserPreviewBlankUrl', () => { + it('should detect about:blank variants', () => { + expect(isBrowserPreviewBlankUrl('about:blank')).toBe(true); + expect(isBrowserPreviewBlankUrl(' about:blank ')).toBe(true); + expect(isBrowserPreviewBlankUrl('ABOUT:BLANK')).toBe(true); + expect(isBrowserPreviewBlankUrl('about:blank#')).toBe(true); + }); + + it('should reject non-blank urls and nullish values', () => { + expect(isBrowserPreviewBlankUrl(null)).toBe(false); + expect(isBrowserPreviewBlankUrl(undefined)).toBe(false); + expect(isBrowserPreviewBlankUrl('https://example.com')).toBe(false); + expect(isBrowserPreviewBlankUrl('about:srcdoc')).toBe(false); + }); + }); +}); diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/browser-preview/browser-preview.utils.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/browser-preview/browser-preview.utils.ts new file mode 100644 index 000000000..cd40e25e8 --- /dev/null +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/browser-preview/browser-preview.utils.ts @@ -0,0 +1,146 @@ +/** + * Map a pointer event on a canvas to CDP mouse coordinates using screencast metadata. + */ +export function mapCanvasPointerToDeviceCoordinates(options: { + clientX: number; + clientY: number; + canvasRect: { left: number; top: number; width: number; height: number }; + deviceWidth: number; + deviceHeight: number; +}): { x: number; y: number } { + const { clientX, clientY, canvasRect, deviceWidth, deviceHeight } = options; + const relativeX = canvasRect.width > 0 ? (clientX - canvasRect.left) / canvasRect.width : 0; + const relativeY = canvasRect.height > 0 ? (clientY - canvasRect.top) / canvasRect.height : 0; + const x = Math.max(0, Math.min(deviceWidth, relativeX * deviceWidth)); + const y = Math.max(0, Math.min(deviceHeight, relativeY * deviceHeight)); + + return { x, y }; +} + +export function mapDomMouseButton(button: number): 'none' | 'left' | 'middle' | 'right' { + switch (button) { + case 0: + return 'left'; + case 1: + return 'middle'; + case 2: + return 'right'; + default: + return 'none'; + } +} + +export type CdpKeyEventPayload = { + type: 'keyDown' | 'keyUp' | 'rawKeyDown' | 'char'; + text?: string; + unmodifiedText?: string; + key?: string; + code?: string; + windowsVirtualKeyCode?: number; + nativeVirtualKeyCode?: number; + modifiers?: number; +}; + +export function mapDomModifiers(event: { + altKey: boolean; + ctrlKey: boolean; + metaKey: boolean; + shiftKey: boolean; +}): number { + return (event.altKey ? 1 : 0) | (event.ctrlKey ? 2 : 0) | (event.metaKey ? 4 : 0) | (event.shiftKey ? 8 : 0); +} + +/** + * Map a DOM keyboard event to one or more CDP Input.dispatchKeyEvent payloads. + * Enter/Tab and printable characters need a follow-up `char` event for Chromium to accept them. + */ +export function mapDomKeyboardEventToCdpKeyEvents( + event: { + key: string; + code: string; + keyCode: number; + altKey: boolean; + ctrlKey: boolean; + metaKey: boolean; + shiftKey: boolean; + }, + phase: 'keyDown' | 'keyUp', +): CdpKeyEventPayload[] { + const modifiers = mapDomModifiers(event); + const windowsVirtualKeyCode = event.keyCode || 0; + const base = { + key: event.key, + code: event.code, + windowsVirtualKeyCode, + nativeVirtualKeyCode: windowsVirtualKeyCode, + modifiers, + }; + + if (phase === 'keyUp') { + return [{ ...base, type: 'keyUp' as const }]; + } + + const events: CdpKeyEventPayload[] = [{ ...base, type: 'rawKeyDown' }]; + let text: string | undefined; + + if (event.key === 'Enter') { + text = '\r'; + } else if (event.key === 'Tab') { + text = '\t'; + } else if (event.key.length === 1 && !event.ctrlKey && !event.altKey && !event.metaKey) { + text = event.key; + } + + if (text !== undefined) { + events.push({ + type: 'char', + text, + unmodifiedText: text, + key: event.key, + code: event.code, + windowsVirtualKeyCode, + nativeVirtualKeyCode: windowsVirtualKeyCode, + modifiers, + }); + } + + return events; +} + +/** + * Normalize a user-typed address-bar value into an absolute http(s) URL, or null if invalid. + */ +export function normalizeBrowserPreviewNavigateUrl(raw: string): string | null { + const trimmed = raw.trim(); + + if (!trimmed || trimmed.length > 2048) { + return null; + } + + const withScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed) ? trimmed : `https://${trimmed}`; + + try { + const url = new URL(withScheme); + + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return null; + } + + return url.toString(); + } catch { + return null; + } +} + +/** + * True when Preview is on a blank start page (guide overlay should show). + */ +export function isBrowserPreviewBlankUrl(url: string | null | undefined): boolean { + if (url == null) { + return false; + } + + const trimmed = url.trim().toLowerCase(); + + return trimmed === 'about:blank' || trimmed.startsWith('about:blank#') || trimmed.startsWith('about:blank?'); +} diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/sockets/sockets.facade.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/sockets/sockets.facade.ts index 59fdc59c7..3d7e7e646 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/sockets/sockets.facade.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/sockets/sockets.facade.ts @@ -334,6 +334,44 @@ export class SocketsFacade { this.forwardEvent(ForwardableEvent.CLOSE_TERMINAL, { sessionId }, agentId); } + /** + * Start a browser-only Preview session for an agent. + */ + forwardStartBrowserPreview(sessionId: string | undefined, agentId: string): void { + this.forwardEvent(ForwardableEvent.START_BROWSER_PREVIEW, { sessionId }, agentId); + } + + /** + * Send mouse/keyboard input to an active browser Preview session. + */ + forwardBrowserPreviewInput( + sessionId: string, + kind: 'mouse' | 'key', + event: Record, + agentId: string, + ): void { + this.forwardEvent(ForwardableEvent.BROWSER_PREVIEW_INPUT, { sessionId, kind, event }, agentId); + } + + /** + * Send a navigation / chrome command to an active browser Preview session. + */ + forwardBrowserPreviewCommand( + sessionId: string, + command: 'navigate' | 'reload' | 'back' | 'forward', + agentId: string, + url?: string, + ): void { + this.forwardEvent(ForwardableEvent.BROWSER_PREVIEW_COMMAND, { sessionId, command, url }, agentId); + } + + /** + * Stop an active browser Preview session. + */ + forwardStopBrowserPreview(sessionId: string, agentId: string): void { + this.forwardEvent(ForwardableEvent.STOP_BROWSER_PREVIEW, { sessionId }, agentId); + } + /** * Get forwarded events for a specific event name * @param eventName - The event name to filter by diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/sockets/sockets.reducer.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/sockets/sockets.reducer.ts index b1c00c071..1ce77b38a 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/sockets/sockets.reducer.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/sockets/sockets.reducer.ts @@ -371,8 +371,8 @@ export const socketsReducer = createReducer( })), // Forwarded Event Received on(forwardedEventReceived, (state, { event, payload }) => { - // Don't track containerStats events - if (event === 'containerStats') { + // Don't track high-frequency streaming events in the forwardedEvents buffer + if (event === 'containerStats' || event === 'browserPreviewFrame') { return state; } diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/sockets/sockets.types.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/sockets/sockets.types.ts index d7eac2eb2..8db4e0496 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/sockets/sockets.types.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/sockets/sockets.types.ts @@ -27,6 +27,10 @@ export enum ForwardableEvent { CREATE_TERMINAL = 'createTerminal', TERMINAL_INPUT = 'terminalInput', CLOSE_TERMINAL = 'closeTerminal', + START_BROWSER_PREVIEW = 'startBrowserPreview', + BROWSER_PREVIEW_INPUT = 'browserPreviewInput', + BROWSER_PREVIEW_COMMAND = 'browserPreviewCommand', + STOP_BROWSER_PREVIEW = 'stopBrowserPreview', } /** @@ -51,7 +55,11 @@ export type ForwardableEventPayload = | FileUpdatePayload | CreateTerminalPayload | TerminalInputPayload - | CloseTerminalPayload; + | CloseTerminalPayload + | StartBrowserPreviewPayload + | BrowserPreviewInputPayload + | BrowserPreviewCommandPayload + | StopBrowserPreviewPayload; /** * Chat event payload (from agents.gateway.ts ChatPayload) @@ -189,6 +197,38 @@ export interface CloseTerminalPayload { sessionId: string; } +/** + * Start browser Preview session + */ +export interface StartBrowserPreviewPayload { + sessionId?: string; +} + +/** + * Browser Preview input (mouse or key) + */ +export interface BrowserPreviewInputPayload { + sessionId: string; + kind: 'mouse' | 'key'; + event: Record; +} + +/** + * Browser Preview navigation / chrome command + */ +export interface BrowserPreviewCommandPayload { + sessionId: string; + command: 'navigate' | 'reload' | 'back' | 'forward'; + url?: string; +} + +/** + * Stop browser Preview session + */ +export interface StopBrowserPreviewPayload { + sessionId: string; +} + /** * Acknowledgement for forwarded events */ @@ -320,6 +360,49 @@ export interface TerminalClosedData { sessionId: string; } +/** + * Browser Preview session started + */ +export interface BrowserPreviewStartedData { + sessionId: string; + /** Docker DNS name of the workspace container, when available. */ + workspaceHostname?: string; +} + +/** + * Browser Preview screencast frame + */ +export interface BrowserPreviewFrameData { + sessionId: string; + data: string; + metadata: { + offsetTop: number; + pageScaleFactor: number; + deviceWidth: number; + deviceHeight: number; + scrollOffsetX: number; + scrollOffsetY: number; + timestamp: number; + }; +} + +/** + * Browser Preview session stopped + */ +export interface BrowserPreviewStoppedData { + sessionId: string; +} + +/** + * Browser Preview location / history state + */ +export interface BrowserPreviewLocationData { + sessionId: string; + url: string; + canGoBack: boolean; + canGoForward: boolean; +} + /** * Message filter result data (from agents.gateway.ts MessageFilterResultData) */ @@ -450,6 +533,10 @@ export type ForwardedEventPayload = | SuccessResponse // terminalCreated | SuccessResponse // terminalOutput | SuccessResponse // terminalClosed + | SuccessResponse // browserPreviewStarted + | SuccessResponse // browserPreviewFrame + | SuccessResponse // browserPreviewStopped + | SuccessResponse // browserPreviewLocation | SuccessResponse // containerStats | SuccessResponse // chatEvent | TicketAutomationRunChatEventPayload // ticketAutomationRunChatUpsert diff --git a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/agent-console.routes.ts b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/agent-console.routes.ts index ab8cca2f0..5379e9a23 100644 --- a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/agent-console.routes.ts +++ b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/agent-console.routes.ts @@ -7,6 +7,9 @@ import { approveTicketAutomation$, AtlassianContextImportFacade, atlassianContextImportReducer, + BrowserPreviewFacade, + browserPreviewReducer, + closeBrowserPreviewUiStopsSession$, cancelRun$, cancelTicketAutomationRun$, clearExternalImportMarkers$, @@ -97,6 +100,13 @@ import { loadGitStatus$, loadJobLogs$, loadKnowledgeActivity$, + onBrowserPreviewFrame$, + onBrowserPreviewSocketError$, + onBrowserPreviewStarted$, + onBrowserPreviewStopped$, + onBrowserPreviewLocation$, + startBrowserPreview$, + stopBrowserPreview$, loadKnowledgeRelations$, loadKnowledgeTree$, loadProvisioningProviders$, @@ -317,6 +327,7 @@ export const agentConsoleRoutes: Route[] = [ provideAgenstraNotificationAdminClientProvider(), // Facades AgentsFacade, + BrowserPreviewFacade, ClientsFacade, SocketsFacade, TicketsBoardSocketFacade, @@ -338,6 +349,7 @@ export const agentConsoleRoutes: Route[] = [ // Feature states - registered at feature level for lazy loading provideState('clients', clientsReducer), provideState('agents', agentsReducer), + provideState('browserPreview', browserPreviewReducer), provideState('sockets', socketsReducer), provideState('ticketsBoardSocket', ticketsBoardSocketReducer), provideState('knowledgeBoardSocket', knowledgeBoardSocketReducer), @@ -389,6 +401,14 @@ export const agentConsoleRoutes: Route[] = [ restartClientAgent$, connectSocket$, disconnectSocket$, + startBrowserPreview$, + stopBrowserPreview$, + closeBrowserPreviewUiStopsSession$, + onBrowserPreviewStarted$, + onBrowserPreviewFrame$, + onBrowserPreviewStopped$, + onBrowserPreviewLocation$, + onBrowserPreviewSocketError$, restoreClientContext$, restoreAgentLogin$, connectKnowledgeBoardSocket$, diff --git a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat.component.html b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat.component.html index 771796f38..8f6618e42 100644 --- a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat.component.html +++ b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat.component.html @@ -751,11 +751,25 @@
Chat
} @if (activeClient$ | async; as activeClient) { @if (selectedAgent$ | async; as selectedAgent) { + @if (selectedAgent.browserPreview?.enabled) { + + } @if (selectedAgent.vnc?.port && (activeClient$ | async)) { @@ -2314,6 +2328,26 @@ + +@if (browserPreviewOpen$ | async) { + +} diff --git a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat.component.scss b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat.component.scss index b4160080f..8cda02d37 100644 --- a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat.component.scss +++ b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat.component.scss @@ -553,3 +553,119 @@ span { border-radius: 0.25rem; background-color: rgba(var(--bs-tertiary-bg-rgb), var(--bs-bg-opacity)) !important; } + +.browser-preview-modal { + background-color: rgba(var(--bs-emphasis-color-rgb), 0.35); + + .modal-dialog { + height: 100%; + margin: 0; + } + + .modal-content { + display: flex; + flex-direction: column; + height: 100%; + max-height: 100vh; + } + + .modal-header { + flex-shrink: 0; + } + + .browser-preview-modal-body { + flex: 1 1 0; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + } + + .browser-preview-toolbar { + flex-shrink: 0; + border-bottom-color: var(--bs-border-color) !important; + } + + .browser-preview-surface { + position: relative; + flex: 1 1 0; + min-height: 0; + min-width: 0; + width: 100%; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + } + + .browser-preview-status { + position: absolute; + z-index: 2; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + max-width: calc(100% - 2rem); + } + + .browser-preview-guide { + position: absolute; + z-index: 1; + inset: 1.25rem; + margin: auto; + max-width: 40rem; + max-height: calc(100% - 2.5rem); + overflow: auto; + padding: 1.25rem 1.5rem; + } + + .browser-preview-guide__title { + margin: 0 0 0.75rem; + font-size: 1.15rem; + font-weight: 600; + } + + .browser-preview-guide__subtitle { + margin: 1.25rem 0 0.5rem; + font-size: 1rem; + font-weight: 600; + } + + .browser-preview-guide__list { + margin: 0; + padding-left: 1.2rem; + display: grid; + gap: 0.55rem; + font-size: 0.925rem; + line-height: 1.45; + } + + .browser-preview-guide__text, + .browser-preview-guide__example { + margin: 0.35rem 0 0; + font-size: 0.925rem; + line-height: 1.45; + } + + .browser-preview-guide code { + padding: 0.1rem 0.35rem; + border-radius: var(--bs-border-radius-sm); + background-color: var(--bs-secondary-bg); + color: var(--bs-code-color); + font-size: 0.88em; + word-break: break-all; + } + + .browser-preview-canvas { + /* Display size is set in TS to contain within the surface. */ + display: block; + flex: 0 0 auto; + max-width: none; + max-height: none; + cursor: default; + outline: none; + + &--hidden { + display: none; + } + } +} diff --git a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat.component.ts b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat.component.ts index 10fc42552..0f1e23d7d 100644 --- a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat.component.ts +++ b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat.component.ts @@ -20,6 +20,7 @@ import { ActivatedRoute, NavigationEnd, Router, RouterModule } from '@angular/ro import { AgentsFacade, AuthenticationFacade, + BrowserPreviewFacade, ClientAgentAutonomyFacade, ClientsFacade, ContainerType, @@ -29,6 +30,11 @@ import { filterTicketsForTicketContextSuggestions, findPermittedTicketByExactSha, KnowledgeFacade, + mapCanvasPointerToDeviceCoordinates, + mapDomKeyboardEventToCdpKeyEvents, + mapDomMouseButton, + isBrowserPreviewBlankUrl, + normalizeBrowserPreviewNavigateUrl, NotificationsFacade, SocketsFacade, StatsFacade, @@ -75,6 +81,7 @@ import { delay, distinctUntilChanged, filter, + fromEvent, map, Observable, of, @@ -170,6 +177,7 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe private readonly agentsFacade = inject(AgentsFacade); private readonly authFacade = inject(AuthenticationFacade); private readonly socketsFacade = inject(SocketsFacade); + private readonly browserPreviewFacade = inject(BrowserPreviewFacade); protected readonly notificationsFacade = inject(NotificationsFacade); private readonly statsFacade = inject(StatsFacade); private readonly ticketsFacade = inject(TicketsFacade); @@ -920,9 +928,57 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe containerType: undefined, gitRepositorySetupMode: 'clone', gitRepositoryUrl: undefined, + createBrowserPreview: true, createVirtualWorkspace: false, createSshConnection: false, }); + readonly browserPreviewOpen$ = this.browserPreviewFacade.open$; + readonly browserPreviewStarting$ = this.browserPreviewFacade.starting$; + readonly browserPreviewFrameData$ = this.browserPreviewFacade.frameData$; + readonly browserPreviewFrameMetadata$ = this.browserPreviewFacade.frameMetadata$; + readonly browserPreviewError$ = this.browserPreviewFacade.error$; + readonly browserPreviewSessionId$ = this.browserPreviewFacade.sessionId$; + readonly browserPreviewAgentId$ = this.browserPreviewFacade.agentId$; + readonly browserPreviewCurrentUrl$ = this.browserPreviewFacade.currentUrl$; + readonly browserPreviewWorkspaceHostname$ = this.browserPreviewFacade.workspaceHostname$; + readonly browserPreviewCanGoBack$ = this.browserPreviewFacade.canGoBack$; + readonly browserPreviewCanGoForward$ = this.browserPreviewFacade.canGoForward$; + readonly browserPreviewUrlDraft = signal(''); + readonly isBrowserPreviewBlankUrl = isBrowserPreviewBlankUrl; + private browserPreviewCanvasRef?: ElementRef; + private pendingBrowserPreviewFrame: string | null = null; + private browserPreviewFramePixelWidth = 0; + private browserPreviewFramePixelHeight = 0; + private browserPreviewSurfaceResizeObserver?: ResizeObserver; + private browserPreviewPaintInFlight = false; + + @ViewChild('browserPreviewCanvas') + set browserPreviewCanvas(ref: ElementRef | undefined) { + this.browserPreviewSurfaceResizeObserver?.disconnect(); + this.browserPreviewSurfaceResizeObserver = undefined; + this.browserPreviewCanvasRef = ref; + + if (!ref) { + return; + } + + const surface = ref.nativeElement.parentElement; + + if (surface && typeof ResizeObserver !== 'undefined') { + this.browserPreviewSurfaceResizeObserver = new ResizeObserver(() => { + this.fitBrowserPreviewCanvasToSurface(false); + }); + this.browserPreviewSurfaceResizeObserver.observe(surface); + } + + if (this.pendingBrowserPreviewFrame) { + this.paintBrowserPreviewFrame(this.pendingBrowserPreviewFrame); + } else { + this.fitBrowserPreviewCanvasToSurface(); + } + } + + @ViewChild('browserPreviewModal') browserPreviewModal?: ElementRef; // Edit state readonly editingClientId = signal(null); @@ -1190,6 +1246,30 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe // Default chat model to auto mode on load this.socketsFacade.setChatModel(null); + this.browserPreviewFacade.frameData$ + .pipe( + takeUntilDestroyed(this.destroyRef), + filter((data): data is string => !!data), + ) + .subscribe((data) => { + this.paintBrowserPreviewFrame(data); + }); + + fromEvent(window, 'resize') + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.fitBrowserPreviewCanvasToSurface(); + }); + + this.browserPreviewFacade.currentUrl$ + .pipe( + takeUntilDestroyed(this.destroyRef), + filter((url): url is string => !!url), + ) + .subscribe((url) => { + this.browserPreviewUrlDraft.set(url); + }); + this.socketsFacade.chatEnhancementLastResult$ .pipe( takeUntilDestroyed(this.destroyRef), @@ -1883,6 +1963,9 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe } ngOnDestroy(): void { + this.browserPreviewSurfaceResizeObserver?.disconnect(); + this.browserPreviewSurfaceResizeObserver = undefined; + // Cancel any pending sync to prevent callbacks holding component reference if (this.syncAnimationFrameId !== null) { cancelAnimationFrame(this.syncAnimationFrameId); @@ -3814,7 +3897,7 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe createDto.containerType = agentData.containerType; } - // Include boolean fields (default to true if not set) + createDto.createBrowserPreview = !!(agentData.createBrowserPreview || agentData.createVirtualWorkspace); createDto.createVirtualWorkspace = agentData.createVirtualWorkspace ?? false; createDto.createSshConnection = agentData.createSshConnection ?? false; @@ -3837,6 +3920,7 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe containerType: undefined, gitRepositorySetupMode: this.getDefaultAgentGitRepositorySetupMode(), gitRepositoryUrl: undefined, + createBrowserPreview: true, createVirtualWorkspace: false, createSshConnection: false, }); @@ -3938,7 +4022,263 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe } updateAgentField(field: K, value: CreateAgentDto[K]): void { - this.newAgent.update((current) => ({ ...current, [field]: value })); + this.newAgent.update((current) => { + const next = { ...current, [field]: value }; + + if (field === 'createVirtualWorkspace' && value === true) { + next.createBrowserPreview = true; + } + + return next; + }); + } + + onToggleBrowserPreview(agent: AgentResponseDto): void { + if (!agent.browserPreview?.enabled) { + return; + } + + this.browserPreviewFacade.openPreview(agent.id); + } + + onCloseBrowserPreview(): void { + this.pendingBrowserPreviewFrame = null; + this.browserPreviewFramePixelWidth = 0; + this.browserPreviewFramePixelHeight = 0; + this.browserPreviewPaintInFlight = false; + this.browserPreviewFacade.closePreview(); + } + + private paintBrowserPreviewFrame(data: string): void { + this.pendingBrowserPreviewFrame = data; + const canvas = this.browserPreviewCanvasRef?.nativeElement; + + if (!canvas) { + return; + } + + if (this.browserPreviewPaintInFlight) { + return; + } + + this.browserPreviewPaintInFlight = true; + const image = new Image(); + + image.onload = () => { + const latest = this.pendingBrowserPreviewFrame; + + if (!latest) { + this.browserPreviewPaintInFlight = false; + + return; + } + + // Decode the latest pending frame if a newer one arrived while this Image loaded. + if (latest !== data) { + this.browserPreviewPaintInFlight = false; + this.paintBrowserPreviewFrame(latest); + + return; + } + + canvas.width = image.width; + canvas.height = image.height; + this.browserPreviewFramePixelWidth = image.width; + this.browserPreviewFramePixelHeight = image.height; + + const context = canvas.getContext('2d'); + + if (context) { + context.drawImage(image, 0, 0); + } + + this.fitBrowserPreviewCanvasToSurface(); + this.browserPreviewPaintInFlight = false; + + if (this.pendingBrowserPreviewFrame && this.pendingBrowserPreviewFrame !== data) { + this.paintBrowserPreviewFrame(this.pendingBrowserPreviewFrame); + } + }; + image.onerror = () => { + this.browserPreviewPaintInFlight = false; + }; + image.src = `data:image/jpeg;base64,${data}`; + } + + private fitBrowserPreviewCanvasToSurface(allowRetry = true): void { + const canvas = this.browserPreviewCanvasRef?.nativeElement; + const surface = canvas?.parentElement; + + if (!canvas || !surface) { + return; + } + + const pixelWidth = this.browserPreviewFramePixelWidth || canvas.width; + const pixelHeight = this.browserPreviewFramePixelHeight || canvas.height; + + if (!pixelWidth || !pixelHeight) { + return; + } + + if (surface.clientWidth < 1 || surface.clientHeight < 1) { + if (allowRetry) { + requestAnimationFrame(() => this.fitBrowserPreviewCanvasToSurface(false)); + } + + return; + } + + const scale = Math.min(surface.clientWidth / pixelWidth, surface.clientHeight / pixelHeight); + canvas.style.width = `${Math.max(1, Math.floor(pixelWidth * scale))}px`; + canvas.style.height = `${Math.max(1, Math.floor(pixelHeight * scale))}px`; + } + + onBrowserPreviewPointer(event: MouseEvent, type: 'mousePressed' | 'mouseReleased' | 'mouseMoved'): void { + const canvas = this.browserPreviewCanvasRef?.nativeElement; + + if (!canvas) { + return; + } + + combineLatest([ + this.browserPreviewFacade.sessionId$, + this.browserPreviewFacade.agentId$, + this.browserPreviewFacade.frameMetadata$, + ]) + .pipe(take(1)) + .subscribe(([sessionId, agentId, metadata]) => { + if (!sessionId || !agentId || !metadata) { + return; + } + + const { x, y } = mapCanvasPointerToDeviceCoordinates({ + clientX: event.clientX, + clientY: event.clientY, + canvasRect: canvas.getBoundingClientRect(), + deviceWidth: metadata.deviceWidth, + deviceHeight: metadata.deviceHeight, + }); + + this.browserPreviewFacade.sendMouseInput(sessionId, agentId, { + type, + x, + y, + button: mapDomMouseButton(event.button), + clickCount: type === 'mouseMoved' ? 0 : 1, + modifiers: 0, + }); + }); + } + + onBrowserPreviewWheel(event: WheelEvent): void { + event.preventDefault(); + const canvas = this.browserPreviewCanvasRef?.nativeElement; + + if (!canvas) { + return; + } + + combineLatest([ + this.browserPreviewFacade.sessionId$, + this.browserPreviewFacade.agentId$, + this.browserPreviewFacade.frameMetadata$, + ]) + .pipe(take(1)) + .subscribe(([sessionId, agentId, metadata]) => { + if (!sessionId || !agentId || !metadata) { + return; + } + + const { x, y } = mapCanvasPointerToDeviceCoordinates({ + clientX: event.clientX, + clientY: event.clientY, + canvasRect: canvas.getBoundingClientRect(), + deviceWidth: metadata.deviceWidth, + deviceHeight: metadata.deviceHeight, + }); + + this.browserPreviewFacade.sendMouseInput(sessionId, agentId, { + type: 'mouseWheel', + x, + y, + deltaX: event.deltaX, + deltaY: event.deltaY, + modifiers: 0, + }); + }); + } + + onBrowserPreviewKey(event: KeyboardEvent, type: 'keyDown' | 'keyUp'): void { + event.preventDefault(); + event.stopPropagation(); + + combineLatest([this.browserPreviewFacade.sessionId$, this.browserPreviewFacade.agentId$]) + .pipe(take(1)) + .subscribe(([sessionId, agentId]) => { + if (!sessionId || !agentId) { + return; + } + + for (const cdpEvent of mapDomKeyboardEventToCdpKeyEvents(event, type)) { + this.browserPreviewFacade.sendKeyInput(sessionId, agentId, cdpEvent); + } + }); + } + + onBrowserPreviewNavigateSubmit(event?: Event): void { + event?.preventDefault(); + + const url = normalizeBrowserPreviewNavigateUrl(this.browserPreviewUrlDraft()); + + if (!url) { + return; + } + + combineLatest([this.browserPreviewFacade.sessionId$, this.browserPreviewFacade.agentId$]) + .pipe(take(1)) + .subscribe(([sessionId, agentId]) => { + if (!sessionId || !agentId) { + return; + } + + this.browserPreviewFacade.navigate(sessionId, agentId, url); + }); + } + + onBrowserPreviewReload(): void { + combineLatest([this.browserPreviewFacade.sessionId$, this.browserPreviewFacade.agentId$]) + .pipe(take(1)) + .subscribe(([sessionId, agentId]) => { + if (!sessionId || !agentId) { + return; + } + + this.browserPreviewFacade.reload(sessionId, agentId); + }); + } + + onBrowserPreviewBack(): void { + combineLatest([this.browserPreviewFacade.sessionId$, this.browserPreviewFacade.agentId$]) + .pipe(take(1)) + .subscribe(([sessionId, agentId]) => { + if (!sessionId || !agentId) { + return; + } + + this.browserPreviewFacade.goBack(sessionId, agentId); + }); + } + + onBrowserPreviewForward(): void { + combineLatest([this.browserPreviewFacade.sessionId$, this.browserPreviewFacade.agentId$]) + .pipe(take(1)) + .subscribe(([sessionId, agentId]) => { + if (!sessionId || !agentId) { + return; + } + + this.browserPreviewFacade.goForward(sessionId, agentId); + }); } // Helper methods to update editing signal values for form binding diff --git a/package-lock.json b/package-lock.json index 0116880fe..863f28eb8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -107,6 +107,7 @@ "turndown-plugin-gfm": "1.0.2", "typeorm": "0.3.27", "uuid": "11.1.1", + "ws": "8.18.0", "xmlbuilder2": "4.0.3", "zod": "4.3.6", "zone.js": "0.16.1" @@ -12891,28 +12892,6 @@ "node": ">=10" } }, - "node_modules/@module-federation/dts-plugin/node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/@module-federation/enhanced": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@module-federation/enhanced/-/enhanced-2.3.1.tgz", @@ -57874,10 +57853,9 @@ } }, "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", - "dev": true, + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index 0d1d3a2cf..3aee72100 100644 --- a/package.json +++ b/package.json @@ -200,6 +200,7 @@ "turndown-plugin-gfm": "1.0.2", "typeorm": "0.3.27", "uuid": "11.1.1", + "ws": "8.18.0", "xmlbuilder2": "4.0.3", "zod": "4.3.6", "zone.js": "0.16.1"