Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 952e9e2974 fix(cli): clarify daemon executable status label 2026-07-06 16:40:40 +02:00
Mohamed Boudra 887845418e refactor(desktop): share daemon pid lock reader 2026-07-06 16:38:50 +02:00
Mohamed Boudra f7c4ef774d fix(daemon): derive desktop management from executable
Desktop management now follows the daemon runtime path, so daemons started through the bundled CLI remain eligible for desktop upgrade restarts. New locks record executablePath; the old desktopManaged lock bit is accepted only for compatibility with existing locks.
2026-07-06 16:19:00 +02:00
Mohamed Boudra 08a0d0c28d Fix worktree setup scripts losing PATH (#1908)
* fix(worktree): preserve PATH for lifecycle scripts

Lifecycle command strings were running through login shells, which could rewrite PATH before setup commands saw the daemon environment. Use the shared stable script-shell helper without login startup files, and route related project-authored command strings through it.

* fix(worktree): ignore Bash startup env hook

Project command strings should not source shell startup hooks before they see Paseo's supplied environment. Strip BASH_ENV from lifecycle, loop verify, and ACP string-command shells and pin the behavior in setup tests.

* fix(worktree): resolve Bash through PATH

* fix(worktree): preserve Windows cmd command strings

* test(acp): expect Windows cmd terminal strings
2026-07-06 20:34:42 +08:00
Xisheng Parker Zhao 61df450366 fix(server): surface TRAE CLI slash commands and skills over ACP (#1896)
traecli publishes its slash commands and skills asynchronously via the standard
ACP available_commands_update notification ~400ms after session/new resolves.
Because the catalog entry runs through the plain GenericACPAgentClient
(waitForInitialCommands defaults to false), listCommands() resolves before that
first batch arrives and the Paseo UI shows an empty slash menu — intermittently,
depending on whether the menu is opened inside the ~400ms race window.

Add a thin TraeACPAgentClient that sets waitForInitialCommands: true (10s
timeout) and wire it into the extends:"acp" derived-provider branch, mirroring
CursorACPAgentClient exactly. Cursor has the identical async-commands behavior;
this reuses that adopted pattern rather than introducing anything provider-
specific. Unlike Kiro (#1792), traecli uses the standard available_commands_update
update type, so no extensionCommandsParser is needed.
2026-07-06 20:30:22 +08:00
paseo-ai[bot] 5d25ed6bf8 fix: update lockfile signatures and Nix hash [skip ci] 2026-07-06 08:03:12 +00:00
Mohamed Boudra 391cf75ece chore(release): cut 0.1.104-beta.4 2026-07-06 09:58:20 +02:00
Mohamed Boudra 7ee2d6fd7e docs: changelog for v0.1.104-beta.4 2026-07-06 09:56:20 +02:00
Mohamed Boudra b1d11fccd5 Hide browser tools unless enabled
Browser tools are now hidden from agent tool catalogs unless daemon browser tools are enabled.
2026-07-05 19:02:59 +02:00
paseo-ai[bot] 05f8dda1e3 fix: update lockfile signatures and Nix hash [skip ci] 2026-07-05 16:11:29 +00:00
Mohamed Boudra 9968f2ba50 Speed up inbound WebSocket validation (#1895)
* perf(protocol): generate inbound ws validators

* perf(client): use generated ws validation

* docs(protocol): document generated validation

* fix(protocol): make validator generation source-only

* test(protocol): cover explicit provider model normalization

* fix(protocol): preserve inbound compat defaults

* fix(protocol): make validator import rewrite portable

* fix(protocol): harden validation safety checks

* fix(protocol): guard generated validator boundaries

* fix(protocol): normalize legacy inbound defaults

* fix(protocol): simplify generated validation safety net

* fix(protocol): keep cold typecheck source-only

* fix(protocol): encapsulate validator codegen

* fix(protocol): bootstrap source-alias typechecks

* fix(app): keep source aliases out of bundler config

* fix(protocol): harden validator codegen packaging

* fix(protocol): own zod-aot patches in generator

* fix(protocol): trim validator safety net

* fix(client): normalize provider updates before dispatch

* fix(protocol): keep validator generation out of install
2026-07-06 00:07:54 +08:00
paseo-ai[bot] 0ecf2fb8da fix: update lockfile signatures and Nix hash [skip ci] 2026-07-04 08:26:52 +00:00
Mohamed Boudra b3eb77981a chore(release): cut 0.1.104-beta.3 2026-07-04 10:22:14 +02:00
Mohamed Boudra 3e28b9b59e chore(providers): update ACP catalog pins 2026-07-04 10:20:07 +02:00
Mohamed Boudra 42d1a2e4e3 docs(browser): document browser automation 2026-07-04 10:19:53 +02:00
Mohamed Boudra e98d8c7a36 Auto-title workspaces created by agents (#1887)
* Auto-name workspaces created by agents

* Require workspace auto-namer in websocket harnesses

* Auto-title checkout worktrees without branch renames
2026-07-04 09:52:00 +02:00
Mohamed Boudra 9c220c2451 Browser tools: full accessibility snapshot, trusted input, dialogs, evaluate, and tab controls (#1881)
* feat(browser): replace flat snapshot with ARIA tree and page-side refs

* feat(browser): trusted CDP input with actionability waits

* feat(browser): auto-handle javascript dialogs

* feat(browser): add browser_evaluate

* feat(browser): add scroll, resize, and close_tab tools

* fix(browser): review fixes for snapshot/input/dialog seams

* fix(browser): address review findings on dialog capture, truncation, and keypress targets

* fix(harness): drop resize check that cannot run under parked compositor surfaces

* fix(browser): second review round — snapshot fidelity, key input, dialog scoping
2026-07-04 01:38:07 +02:00
Mohamed Boudra b28f291493 Generalize browser automation hosting beyond the desktop app (#1878)
* feat(browser): generalize browser automation hosts

Rename the browser automation client capability to a host-neutral payload, advertise supported commands, and route tab-scoped commands by browserId affinity so future browser hosts can register without another protocol shape change.

* fix(browser): harden browser host registration

Reject unusable browser host capabilities, clean up pending requests when a host registration is replaced, and defer list_tabs affinity updates until every host in the aggregation succeeds.

* fix(browser): preserve host tab affinity across reconnects

Keep stranded tab ownership tied to the disconnected host so the same host can reclaim open tabs after reconnect, and tolerate future command names in browser_host capability payloads while retaining only known commands.
2026-07-04 01:46:04 +08:00
Mohamed Boudra f226222638 Build Docker images from source (#1877)
* fix(docker): build images from source

Collapse the npm-install and source-build image paths into one Dockerfile so every Docker image is built from the checked-out workspace packages. Include procps in the runtime image because provider process cleanup depends on ps through tree-kill on Linux.

* fix(docker): include native speech packages

* fix(docker): cover source build inputs

* fix(docker): ignore local agent artifacts
2026-07-03 15:49:23 +02:00
Mohamed Boudra 6ce4e3f507 Let agents rename workspaces (#1876)
* feat(agent): let agents rename workspaces

* fix(agent): scope workspace rename tool

* fix(agent): allow explicit workspace renames
2026-07-03 15:41:01 +02:00
Mohamed Boudra 8d3a43e05d Stop new browser tabs from an agent stealing your focus (#1875)
* fix(browser): stop new MCP browser tabs stealing focus

browser_new_tab used openTabFocused, switching the workspace UI to
the new tab and interrupting whatever the user was looking at.
Automation commands don't need visual focus to work: webviews stay
resident and paintable regardless, so open the tab in the background
via openTabInBackground instead.

* fix(browser): stop background MCP tabs hijacking Reload

openBrowserTabForRequest still called setWorkspaceActiveBrowser for
tabs opened in the background, so the desktop Reload/Force Reload
menu (which targets getMostRecentWorkspaceActivePaseoBrowserWebContents)
could reload an agent's hidden tab instead of whatever the user was
looking at. Automation commands never needed this: every tab-scoped
MCP tool takes an explicit browserId, and the existing
useSyncWorkspaceActiveBrowser hook already keeps "active browser" in
sync with real UI focus. Drop the redundant call.
2026-07-03 13:19:24 +02:00
Mohamed Boudra 7cb0ccba60 Show Claude Ultra Code where supported (#1872)
* fix(providers): show Claude Ultra Code by capability

Expose Ultra Code for xhigh-capable Claude models and keep first-party Claude model metadata in one manifest so future model updates do not need scattered feature lists.

* fix(providers): preserve Claude fast mode aliases

Normalize versioned Claude model IDs through the manifest before checking fast-mode support so dated Opus IDs keep the same feature surface as their base model.

* fix(providers): keep Claude fast mode first-party

Anchor Claude model normalization so custom settings models that merely contain first-party IDs do not inherit manifest-only fast mode.

* fix(providers): preserve Claude runtime aliases

Keep Fast Mode capability checks strict to manifest IDs while allowing provider-form Claude runtime model strings to resolve known model metadata.
2026-07-03 18:49:59 +08:00
paseo-ai[bot] f1a8e9a563 fix: update lockfile signatures and Nix hash [skip ci] 2026-07-03 10:02:44 +00:00
125 changed files with 10366 additions and 2072 deletions
+20
View File
@@ -1,11 +1,31 @@
.git
.debug.conversations
.debug
.dev
.playwright-mcp
**/.playwright-mcp
.paseo
**/.paseo-provider-history
.plans
.tasks
.valknut
.claude/settings.local.json
**/.claude/settings.local.json
.claude/scheduled_tasks.lock
.claude/worktrees
.wrangler
**/.wrangler
**/.tanstack
PLAN.md
valknut-report.html
valknut-report.json
.env*
**/.env*
.dev.vars
**/.dev.vars
*.pem
**/*.pem
**/.secrets
**/node_modules
**/dist
**/build
-6
View File
@@ -76,7 +76,6 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Build server stack
run: npm run build:server
@@ -113,7 +112,6 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Install agent CLIs for provider tests
run: npm install -g @anthropic-ai/claude-code opencode-ai
@@ -170,7 +168,6 @@ jobs:
}
Start-Sleep -Seconds (20 * $attempt)
}
- name: Build server stack
run: npm run build:server
@@ -202,7 +199,6 @@ jobs:
fi
sleep $((attempt * 20))
done
- name: Install Playwright browsers
timeout-minutes: 10
run: npx playwright install chromium
@@ -227,7 +223,6 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Build client dependencies
run: npm run build:client
@@ -270,7 +265,6 @@ jobs:
fi
sleep $((attempt * 20))
done
- name: Install Playwright browsers
timeout-minutes: 10
run: npx playwright install chromium
-1
View File
@@ -27,7 +27,6 @@ jobs:
run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build app dependencies
run: npm run build:app-deps
+23 -39
View File
@@ -5,7 +5,22 @@ on:
branches: [main]
paths:
- "docker/**"
- ".dockerignore"
- ".github/workflows/docker.yml"
- "package.json"
- "package-lock.json"
- "patches/**"
- "scripts/**"
- "tsconfig.json"
- "tsconfig.base.json"
- "packages/app/**"
- "packages/cli/**"
- "packages/client/**"
- "packages/expo-two-way-audio/**"
- "packages/highlight/**"
- "packages/protocol/**"
- "packages/relay/**"
- "packages/server/**"
push:
branches: [main]
tags:
@@ -13,7 +28,7 @@ on:
workflow_dispatch:
inputs:
paseo_version:
description: "Paseo version to build. Required when publish is true."
description: "Expected source version to build. Required when publish is true."
required: false
default: ""
publish:
@@ -24,15 +39,6 @@ on:
options:
- "false"
- "true"
source_build:
description: "Build from the checked-out source tree instead of npm."
required: false
default: "auto"
type: choice
options:
- auto
- "false"
- "true"
publish_latest:
description: "Also publish ghcr.io/getpaseo/paseo:latest. Ignored for prereleases."
required: false
@@ -57,7 +63,6 @@ jobs:
image: ${{ steps.values.outputs.image }}
install_version: ${{ steps.values.outputs.install_version }}
publish: ${{ steps.values.outputs.publish }}
source_build: ${{ steps.values.outputs.source_build }}
check_tag: ${{ steps.values.outputs.check_tag }}
publish_tags: ${{ steps.values.outputs.publish_tags }}
steps:
@@ -68,7 +73,6 @@ jobs:
INPUT_PASEO_VERSION: ${{ inputs.paseo_version }}
INPUT_PUBLISH: ${{ inputs.publish }}
INPUT_PUBLISH_LATEST: ${{ inputs.publish_latest }}
INPUT_SOURCE_BUILD: ${{ inputs.source_build }}
REPO_OWNER: ${{ github.repository_owner }}
REF_NAME: ${{ github.ref_name }}
run: |
@@ -76,9 +80,9 @@ jobs:
owner="$(printf '%s' "${REPO_OWNER}" | tr '[:upper:]' '[:lower:]')"
image="ghcr.io/${owner}/paseo"
install_version="${INPUT_PASEO_VERSION:-latest}"
package_version="$(node -p "require('./package.json').version")"
install_version="${INPUT_PASEO_VERSION:-${package_version}}"
publish=false
source_build=false
publish_latest=false
prerelease=false
@@ -87,7 +91,6 @@ jobs:
publish=true
if [[ "${REF_NAME}" == *-* ]]; then
prerelease=true
source_build=true
else
publish_latest=true
fi
@@ -104,24 +107,6 @@ jobs:
prerelease=true
fi
case "${INPUT_SOURCE_BUILD:-auto}" in
true)
source_build=true
;;
false)
source_build=false
;;
auto)
if [[ "${prerelease}" == "true" ]]; then
source_build=true
fi
;;
*)
echo "::error::source_build must be auto, true, or false."
exit 1
;;
esac
if [[ "${INPUT_PUBLISH_LATEST:-false}" == "true" && "${prerelease}" != "true" ]]; then
publish_latest=true
fi
@@ -137,14 +122,13 @@ jobs:
echo "image=${image}"
echo "install_version=${install_version}"
echo "publish=${publish}"
echo "source_build=${source_build}"
echo "check_tag=${check_tag}"
echo "publish_tags<<EOF"
echo "${publish_tags}"
echo "EOF"
} >> "$GITHUB_OUTPUT"
echo "Resolved image=${image} install_version=${install_version} publish=${publish} source_build=${source_build}"
echo "Resolved image=${image} install_version=${install_version} publish=${publish}"
build:
needs: setup
@@ -160,8 +144,8 @@ jobs:
- uses: docker/build-push-action@v7
with:
context: ${{ needs.setup.outputs.source_build == 'true' && '.' || 'docker/base' }}
file: ${{ needs.setup.outputs.source_build == 'true' && 'docker/base/Dockerfile.source' || 'docker/base/Dockerfile' }}
context: .
file: docker/base/Dockerfile
platforms: ${{ env.PLATFORMS }}
build-args: |
PASEO_VERSION=${{ needs.setup.outputs.install_version }}
@@ -193,8 +177,8 @@ jobs:
- uses: docker/build-push-action@v7
with:
context: ${{ needs.setup.outputs.source_build == 'true' && '.' || 'docker/base' }}
file: ${{ needs.setup.outputs.source_build == 'true' && 'docker/base/Dockerfile.source' || 'docker/base/Dockerfile' }}
context: .
file: docker/base/Dockerfile
platforms: ${{ env.PLATFORMS }}
build-args: |
PASEO_VERSION=${{ needs.setup.outputs.install_version }}
+1
View File
@@ -84,6 +84,7 @@ valknut-report.json/
.plans/
packages/server/src/server/fixtures/dictation/dictation-debug-largest.wav
packages/server/src/server/fixtures/dictation/dictation-debug-largest.transcript.txt
packages/protocol/src/generated/validation/*.aot.ts
/artifacts
packages/desktop/.cache/
+8 -2
View File
@@ -1,21 +1,27 @@
# Changelog
## 0.1.104-beta.2 - 2026-07-03
## 0.1.104-beta.4 - 2026-07-06
### Added
- Agents can drive the in-app browser: open tabs, navigate, click, type, and take screenshots
- Agents can drive the in-app browser with page snapshots, trusted input, dialogs, and tab controls ([#1881](https://github.com/getpaseo/paseo/pull/1881))
- Inspect, annotate, and send page elements from a browser tab to the agent ([#1708](https://github.com/getpaseo/paseo/pull/1708) by [@huiliaoning](https://github.com/huiliaoning))
- Schedules screen to create and manage recurring agents ([#1246](https://github.com/getpaseo/paseo/pull/1246))
- Open a project from anywhere with Cmd+O ([#1849](https://github.com/getpaseo/paseo/pull/1849))
- Agents can rename workspaces after they understand the task ([#1876](https://github.com/getpaseo/paseo/pull/1876))
- Claude Ultra Code is available for supported Claude models ([#1872](https://github.com/getpaseo/paseo/pull/1872))
- ByteDance TRAE CLI available as an agent provider ([#1831](https://github.com/getpaseo/paseo/pull/1831) by [@park0er](https://github.com/park0er))
### Improved
- Large provider and model refreshes load faster in the app ([#1895](https://github.com/getpaseo/paseo/pull/1895))
- Workspaces created by agents now get readable generated names ([#1887](https://github.com/getpaseo/paseo/pull/1887))
- Browser tabs opened by agents stay in the background until you switch to them ([#1875](https://github.com/getpaseo/paseo/pull/1875))
- Clearer cards when an agent asks a question ([#1643](https://github.com/getpaseo/paseo/pull/1643) by [@cleiter](https://github.com/cleiter))
### Fixed
- Docker images keep running during provider cleanup and diagnostics ([#1877](https://github.com/getpaseo/paseo/pull/1877))
- New Workspace drafts survive archiving a workspace ([#1838](https://github.com/getpaseo/paseo/pull/1838))
- Composer autocomplete stays open after switching screens ([#1851](https://github.com/getpaseo/paseo/pull/1851))
- Claude usage appears when a quota window has no scheduled reset ([#1855](https://github.com/getpaseo/paseo/pull/1855))
+5 -1
View File
@@ -40,6 +40,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/service-proxy.md](docs/service-proxy.md) | Service proxy: exposing workspace scripts at public URLs, DNS setup, reverse proxy config |
| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP |
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
| [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules |
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization |
| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows |
@@ -92,10 +93,13 @@ See [docs/development.md](docs/development.md) for full setup, build sync requir
- `npm run format:files -- CLAUDE.md packages/app/src/components/message.tsx`
- **The protocol stays backward-compatible. Features don't have to.** Two separate contracts:
- **Protocol contract (always):** schema changes must not break parsing in either direction. An old client must still parse messages from a new daemon; a new daemon must still parse messages from an old client.
- New fields: `.optional()` with a sensible default or `.transform()` fallback.
- New fields: `.optional()` with a sensible default.
- Never flip optional → required, remove fields, or narrow types (`string` → `enum`, `nullable` → non-null).
- Removed fields stay accepted (we stop sending them, not stop reading them).
- Test with: "does a 6-month-old client still parse this?" and "does a 6-month-old daemon still send something this client accepts?"
- Wire schemas are pure structural declarations. Do not add `.transform()`, `.catch()`, or `.preprocess()` to WebSocket message schemas; put normalization in an explicit post-validation pass.
- Plain `z.union()` is forbidden when every branch has a shared literal tag. Use `z.discriminatedUnion()` unless generated-code regression tests prove that specific shape is miscompiled.
- `.default()` is acceptable on primitive leaves only. Never put defaults on item schemas for large arrays or big inbound containers.
- **Feature contract (per-feature):** a new feature may require a new daemon capability. The client detects whether the capability is present and either runs the feature or shows "Update the host to use this." That's it.
- **No fallback paths.** Don't write a degraded version of a new feature that runs on old daemons. Don't fan out across legacy RPCs to simulate a missing capability. The user upgrades or doesn't get the feature.
- **No defensive branches scattered through the feature.** Capability detection happens in one place; downstream code reads a clean shape.
+30 -6
View File
@@ -1,9 +1,32 @@
# syntax=docker/dockerfile:1
ARG NODE_IMAGE=node:22-bookworm-slim
FROM ${NODE_IMAGE}
FROM --platform=$BUILDPLATFORM ${NODE_IMAGE} AS source-pack
ARG PASEO_VERSION=latest
ARG PASEO_VERSION
ENV ONNXRUNTIME_NODE_INSTALL=skip
WORKDIR /tmp/paseo-src
COPY . .
RUN set -eux; \
if [ -n "${PASEO_VERSION:-}" ]; then \
test "$(node -p "require('./package.json').version")" = "${PASEO_VERSION}"; \
fi; \
node -e 'const fs=require("node:fs"); const pkg=JSON.parse(fs.readFileSync("package.json","utf8")); delete pkg.scripts.prepare; fs.writeFileSync("package.json", `${JSON.stringify(pkg)}\n`);'; \
npm ci
RUN set -eux; \
mkdir -p /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/highlight --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/relay --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/protocol --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/client --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/server --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/cli --pack-destination /tmp/paseo-packs
FROM ${NODE_IMAGE}
ENV HOME=/home/paseo \
PASEO_HOME=/home/paseo/.paseo \
@@ -29,13 +52,14 @@ RUN set -eux; \
gosu \
lbzip2 \
openssh-client \
procps \
tini; \
rm -rf /var/lib/apt/lists/*
COPY --from=source-pack /tmp/paseo-packs /tmp/paseo-packs
RUN set -eux; \
npm install -g --omit=optional \
"@getpaseo/server@${PASEO_VERSION}" \
"@getpaseo/cli@${PASEO_VERSION}"; \
npm install -g /tmp/paseo-packs/*.tgz; \
rm -rf /tmp/paseo-packs; \
npm cache clean --force; \
server_entry="$(npm root -g)/@getpaseo/server/dist/scripts/supervisor-entrypoint.js"; \
test -f "$server_entry"; \
@@ -66,7 +90,7 @@ RUN set -eux; \
"$XDG_CACHE_HOME"; \
chown -R paseo:paseo /home/paseo /workspace
COPY rootfs/ /
COPY docker/base/rootfs/ /
RUN chmod +x /usr/local/bin/paseo-docker-entrypoint
WORKDIR /workspace
-101
View File
@@ -1,101 +0,0 @@
# syntax=docker/dockerfile:1
ARG NODE_IMAGE=node:22-bookworm-slim
FROM --platform=$BUILDPLATFORM ${NODE_IMAGE} AS source-pack
ARG PASEO_VERSION
ENV ONNXRUNTIME_NODE_INSTALL=skip
WORKDIR /tmp/paseo-src
COPY . .
RUN set -eux; \
test "$(node -p "require('./package.json').version")" = "${PASEO_VERSION}"; \
node -e 'const fs=require("node:fs"); const pkg=JSON.parse(fs.readFileSync("package.json","utf8")); delete pkg.scripts.prepare; fs.writeFileSync("package.json", `${JSON.stringify(pkg)}\n`);'; \
npm ci
RUN set -eux; \
mkdir -p /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/highlight --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/relay --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/protocol --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/client --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/server --pack-destination /tmp/paseo-packs; \
npm pack --workspace=@getpaseo/cli --pack-destination /tmp/paseo-packs
FROM ${NODE_IMAGE}
ENV HOME=/home/paseo \
PASEO_HOME=/home/paseo/.paseo \
PASEO_LISTEN=0.0.0.0:6767 \
PASEO_WEB_UI_ENABLED=true \
PASEO_LOG_FORMAT=json \
PASEO_LOG_LEVEL=info \
CLAUDE_CONFIG_DIR=/home/paseo/.claude \
CODEX_HOME=/home/paseo/.codex \
XDG_CONFIG_HOME=/home/paseo/.config \
XDG_DATA_HOME=/home/paseo/.local/share \
XDG_STATE_HOME=/home/paseo/.local/state \
XDG_CACHE_HOME=/home/paseo/.cache \
ONNXRUNTIME_NODE_INSTALL=skip
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends \
bash \
ca-certificates \
curl \
git \
gosu \
lbzip2 \
openssh-client \
tini; \
rm -rf /var/lib/apt/lists/*
COPY --from=source-pack /tmp/paseo-packs /tmp/paseo-packs
RUN set -eux; \
npm install -g --omit=optional /tmp/paseo-packs/*.tgz; \
rm -rf /tmp/paseo-packs; \
npm cache clean --force; \
server_entry="$(npm root -g)/@getpaseo/server/dist/scripts/supervisor-entrypoint.js"; \
test -f "$server_entry"; \
printf '%s\n' "$server_entry" > /etc/paseo-server-entry; \
node --check "$server_entry"
RUN set -eux; \
existing_group="$(getent group 1000 | cut -d: -f1 || true)"; \
if [ -n "$existing_group" ] && [ "$existing_group" != "paseo" ]; then \
groupmod --new-name paseo "$existing_group"; \
elif [ -z "$existing_group" ]; then \
groupadd --gid 1000 paseo; \
fi; \
existing_user="$(getent passwd 1000 | cut -d: -f1 || true)"; \
if [ -n "$existing_user" ] && [ "$existing_user" != "paseo" ]; then \
usermod --login paseo --gid paseo --home /home/paseo --shell /bin/bash "$existing_user"; \
elif [ -z "$existing_user" ]; then \
useradd --uid 1000 --gid paseo --create-home --home-dir /home/paseo --shell /bin/bash paseo; \
fi; \
mkdir -p \
/workspace \
"$PASEO_HOME" \
"$CLAUDE_CONFIG_DIR" \
"$CODEX_HOME" \
"$XDG_CONFIG_HOME" \
"$XDG_DATA_HOME" \
"$XDG_STATE_HOME" \
"$XDG_CACHE_HOME"; \
chown -R paseo:paseo /home/paseo /workspace
COPY docker/base/rootfs/ /
RUN chmod +x /usr/local/bin/paseo-docker-entrypoint
WORKDIR /workspace
EXPOSE 6767
VOLUME ["/home/paseo"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD node -e "const listen=process.env.PASEO_LISTEN||'0.0.0.0:6767'; const m=listen.match(/:(\\d+)$/); const port=m?Number(m[1]):6767; require('http').get({hostname:'127.0.0.1',port,path:'/api/health'},r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/paseo-docker-entrypoint"]
+12
View File
@@ -21,6 +21,18 @@ Run it with the repo Electron:
npm run capture-harness --workspace=@getpaseo/desktop
```
Run the browser automation fixture with:
```bash
PASEO_CAPTURE_HARNESS_GROUP=automation npm run capture-harness --workspace=@getpaseo/desktop
```
The automation group uses a real guest webview to verify the page-side ref contract:
ARIA-like snapshot text includes headings, static text, and controls; refs survive
`pushState` when the element still matches; same-URL rerenders stale old refs; and a
file-input ref can be resolved to a CDP backend node id for upload. It also verifies
page-context evaluation, including passing a resolved ref element as the function argument.
On macOS the harness process must set `app.setActivationPolicy("accessory")` and
hide the Dock icon before creating any window. `showInactive()` only prevents window
focus; a normal Electron app launch can still activate the app and steal focus.
+24
View File
@@ -37,6 +37,30 @@ The `agents/{sanitized-cwd}/` directory name is derived from the agent's `cwd` b
---
## Runtime PID Lock
**Path:** `$PASEO_HOME/paseo.pid`
The daemon writes this file when it starts and removes it when it exits cleanly.
It is a runtime lock, not a durable settings file.
| Field | Type | Description |
| ---------------- | --------------------- | ------------------------------------------------------------------------------------------------------------ |
| `pid` | `number` | Owner process PID for stop/status operations |
| `startedAt` | `string` (ISO 8601) | Lock creation timestamp |
| `hostname` | `string` | Host that created the lock |
| `uid` | `number` | User id that created the lock, or `0` where unavailable |
| `listen` | `string \| null` | Daemon listen target at lock creation; updated when the server binds |
| `executablePath` | `string?` | `process.execPath` for the daemon runtime. Desktop uses this to derive whether the daemon is desktop-managed |
| `desktopManaged` | `boolean?` _(legacy)_ | Legacy spawn-origin flag accepted for old locks only; new locks derive desktop management from executable |
Desktop-managed is a read-time classification by the desktop app: the daemon
executable must be the desktop app executable or live inside the same desktop
install. The CLI reports the raw `executablePath` but does not try to classify
it because an npm-installed CLI has no authoritative desktop install root.
---
## 1. Agent Record
**Path:** `$PASEO_HOME/agents/{project-dir}/{agentId}.json`
+9
View File
@@ -178,6 +178,15 @@ defaults. The default rotation is `10m` x `3` files everywhere.
`worktree.setup` and `worktree.teardown` accept either a multiline shell script or an array
of commands. Both run sequentially.
Lifecycle commands run in the worktree through a stable script shell: `bash`
resolved from `PATH` on macOS/Linux, and PowerShell with `-NoProfile` on
Windows. They inherit the daemon environment plus Paseo's lifecycle variables;
login and interactive shell startup files are not loaded, and Bash's `BASH_ENV`
hook is unset. Daemon-run loop verify checks and ACP single-string terminal
commands use the same non-login Bash behavior on macOS/Linux, but preserve their
existing `cmd.exe /c` string semantics on Windows. Service scripts are separate:
they launch in a terminal and receive the service environment described below.
```json
{
"worktree": {
+9 -10
View File
@@ -10,8 +10,7 @@ The image source lives in [`docker/`](../docker/).
The official image:
- installs `@getpaseo/server` and `@getpaseo/cli` from npm for stable images,
or from source-built workspace tarballs for beta images
- builds `@getpaseo/server` and `@getpaseo/cli` from source-built workspace tarballs
- runs the daemon as the non-root `paseo` user
- listens on `0.0.0.0:6767` inside the container
- enables the bundled daemon web UI with `PASEO_WEB_UI_ENABLED=true`
@@ -190,16 +189,17 @@ See [SECURITY.md](../SECURITY.md) for the daemon trust model.
## Building Locally
```bash
docker build -t paseo:local docker/base
docker build -f docker/base/Dockerfile -t paseo:local .
```
To bake a specific published npm version:
To assert the source tree version while building:
```bash
docker build \
--build-arg PASEO_VERSION=0.1.102 \
-t paseo:0.1.102 \
docker/base
-f docker/base/Dockerfile \
.
```
The Docker workflow builds the image on pull requests and on `main` as a
@@ -216,13 +216,12 @@ pushing a `v*` release tag:
gh workflow run docker.yml \
--ref main \
-f paseo_version=0.1.102-beta.1 \
-f publish=true \
-f source_build=auto
-f publish=true
```
Manual Docker publishes require an explicit `paseo_version`. Prerelease
versions build from the checked-out source tree by default and publish only the
exact prerelease image tag.
Manual Docker publishes require an explicit `paseo_version`. The workflow builds
from the checked-out source tree and publishes only the exact prerelease image
tag for prerelease versions.
The published image is multi-arch for `linux/amd64` and `linux/arm64`.
+1 -1
View File
@@ -73,7 +73,7 @@ Anyone who builds software:
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi, OMP
- One-click ACP provider catalog: CodeWhale, Cursor, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
- Voice mode: dictate prompts or talk through problems hands-free
- MCP server exposes the daemon to other agents (create_agent, send_agent_prompt, schedules, terminals, worktrees)
- MCP server exposes the daemon to other agents (create_agent, send_agent_prompt, schedules, terminals, worktrees, workspace renaming)
- Scheduled agents (cron-style triggers) via app, CLI, and MCP
- Frequent releases (multiple per week)
- Community contributions across packaging, providers, and bug fixes
+42
View File
@@ -0,0 +1,42 @@
# Protocol Validation
The client validates inbound WebSocket messages with a zod-aot generated validator instead of runtime Zod on the hot path. Zod remains the authoring source of truth for schemas and TypeScript types.
The reason is mobile performance. A captured 353 KB provider snapshot cost about 10.9 ms and 5.9 MB allocated per message for `JSON.parse` plus Zod on Hermes. After moving provider-model normalization out of the schema so zod-aot could compile the hot subtree, the generated validator path measured about 2.5 ms and 1.2 MB allocated.
## Runtime Path
`packages/protocol/src/validation/ws-outbound.ts` is the shipped boundary. It calls the generated `WSOutboundMessageSchema.safeParse` and returns the validated data. It does not normalize, repair, or re-validate the generated result.
Generated validators preserve unknown keys where Zod object parsing strips them. The client dispatch path uses known `type` and payload fields, so this passthrough behavior is accepted for inbound messages. The wire format is unchanged.
Provider model normalization is a parser-side compatibility shim in the client consumers that need it. Newer daemons normalize at the provider registry source.
## Codegen Ownership
The protocol package owns generation.
- `packages/protocol/codegen/ws-outbound.compile.ts` is the build-time zod-aot discovery entry.
- `packages/protocol/scripts/generate-validation-aot.mjs` runs the exact-pinned compiler and applies the small local compiler patches before generation.
- `packages/protocol/scripts/watch-validation-aot.mjs` reruns generation while editing protocol sources.
- `packages/protocol/src/generated/validation/ws-outbound.aot.ts` is generated runtime code and is gitignored.
- `packages/protocol/src/validation/ws-outbound-schema-metadata.ts` is runtime schema metadata for zod-aot fallback/default references.
Generation runs from protocol-owned lifecycle hooks: `prebuild`, `pretypecheck`, `pretest`, and `watch`. Installs do not run generation: published packages consume protocol from prebuilt `dist`, and local build/typecheck/test flows generate the source file at the point it is actually needed.
## Regression Tests
zod-aot is exact-pinned and young enough that compiler patches are treated as part of this package. `packages/protocol/tests/validation/ws-outbound.test.ts` keeps small regression tests for the patched cases:
- discriminated-union branch output must propagate `.default()` fields
- current sequential item routing must accept `tool_call`-like status branches
- generated runtime imports must keep `.js` extensions for packaged Node ESM
- the generated WebSocket envelope accepts a minimal valid message and rejects a corrupted one
## Schema Purity
Message schemas are structural declarations. Do not put `.transform()`, `.catch()`, or `.preprocess()` on WebSocket message schemas. If parsed data needs normalization, put it in an explicit consumer or post-validation pass.
Use `z.discriminatedUnion()` when every branch has a shared literal tag. Plain `z.union()` is acceptable only when there is no shared literal discriminator or when a generated-code regression test proves that specific shape is miscompiled.
Defaults are allowed only on primitive leaves. Do not place `.default()` on large arrays, item schemas, or big containers in inbound message schemas.
+2
View File
@@ -18,6 +18,8 @@ Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`), and `omp` (a Pi-compatible built-in backed by the Pi adapter). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
Claude first-party model metadata lives in `packages/server/src/server/agent/providers/claude/model-manifest.ts`. When adding or updating a Claude model, update that manifest only; the model picker thinking options and Claude-specific feature gates are derived from the manifest. Do not add model-specific Claude capability lists in feature code.
Paseo tools are not implemented as MCP tools internally. They live in a shared tool catalog under `packages/server/src/server/agent/tools/`; MCP is only the fallback adapter. A provider that can register runtime tools directly should set `supportsNativePaseoTools: true` and consume `launchContext.paseoTools` in `createSession`/`resumeSession`. When native tools are present, `AgentManager` strips the internal Paseo MCP server from the provider launch config so the provider does not receive the same tools twice. Providers that only know MCP should keep `supportsMcpServers: true` and let the daemon inject `/mcp/agents`.
Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages.
+2 -3
View File
@@ -50,7 +50,7 @@ npm run release:patch
This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no `release-mobile.yml` in this repo.
The Docker workflow builds images on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`. Beta Docker images build from the checked-out source tree so the beta flow can intentionally skip npm publishing.
The Docker workflow builds images from the checked-out source tree on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`.
**Releases are always patch.** "Release paseo", "release stable", "ship stable", and similar always mean a patch bump from the previous stable. Never bump minor or major to trigger a build, ever — minor and major bumps are reserved for genuinely larger product cuts and require an explicit user instruction with the word "minor" or "major". If you find yourself reaching for `release:minor` to retrigger a failed build, you are doing the wrong thing — push a retry tag instead (see "Fixing a failed release build" below).
@@ -281,8 +281,7 @@ and EAS mobile release builds. Use the Docker workflow dispatch instead:
gh workflow run docker.yml \
--ref main \
-f paseo_version=X.Y.Z-beta.N \
-f publish=true \
-f source_build=auto
-f publish=true
```
This replaces `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` in place without touching
+1 -1
View File
@@ -1 +1 @@
sha256-LNETnvGaJsv86DlHSrWNabVqvi5kFfmrCJLAq5Bt3EY=
sha256-s4AXc/WKI2p5CdIJJMBmXiLoO4cNcT71GKfn8z9iknI=
+49 -28
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.104-beta.2",
"version": "0.1.104-beta.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.104-beta.2",
"version": "0.1.104-beta.4",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -21797,9 +21797,9 @@
}
},
"node_modules/get-tsconfig": {
"version": "4.13.6",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
"version": "4.14.0",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
"integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
@@ -28563,9 +28563,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"license": "MIT",
"engines": {
"node": ">=12"
@@ -35069,6 +35069,26 @@
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zod-aot": {
"version": "0.20.4",
"resolved": "https://registry.npmjs.org/zod-aot/-/zod-aot-0.20.4.tgz",
"integrity": "sha512-vVuKSG4MpJw3bRQu7UEd6ziJ8+AJY3JB2rWqshnSIRJmhxWFb3FiZ9QYnYcldKYZRzO7edgid8BxJkGbZg43OA==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.16.0",
"get-tsconfig": "^4.14.0",
"jiti": "^2.7.0",
"picomatch": "^4.0.4",
"unplugin": "^3.0.0"
},
"bin": {
"zod-aot": "dist/cli/index.js"
},
"peerDependencies": {
"zod": "^4.0.0"
}
},
"node_modules/zod-to-json-schema": {
"version": "3.25.1",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
@@ -35132,7 +35152,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.104-beta.2",
"version": "0.1.104-beta.4",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -36150,12 +36170,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.104-beta.2",
"version": "0.1.104-beta.4",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.104-beta.2",
"@getpaseo/protocol": "0.1.104-beta.2",
"@getpaseo/server": "0.1.104-beta.2",
"@getpaseo/client": "0.1.104-beta.4",
"@getpaseo/protocol": "0.1.104-beta.4",
"@getpaseo/server": "0.1.104-beta.4",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -36401,10 +36421,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.1.104-beta.2",
"version": "0.1.104-beta.4",
"dependencies": {
"@getpaseo/protocol": "0.1.104-beta.2",
"@getpaseo/relay": "0.1.104-beta.2",
"@getpaseo/protocol": "0.1.104-beta.4",
"@getpaseo/relay": "0.1.104-beta.4",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -36415,7 +36435,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.104-beta.2",
"version": "0.1.104-beta.4",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -36658,7 +36678,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.104-beta.2",
"version": "0.1.104-beta.4",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -37554,7 +37574,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.104-beta.2",
"version": "0.1.104-beta.4",
"dependencies": {
"@codemirror/language": "^6.12.3",
"@codemirror/legacy-modes": "^6.5.3",
@@ -37786,19 +37806,20 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.1.104-beta.2",
"version": "0.1.104-beta.4",
"dependencies": {
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^20.9.0",
"typescript": "^5.2.2",
"vitest": "^4.1.6"
"vitest": "^4.1.6",
"zod-aot": "0.20.4"
}
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.104-beta.2",
"version": "0.1.104-beta.4",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -38016,15 +38037,15 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.104-beta.2",
"version": "0.1.104-beta.4",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.1.104-beta.2",
"@getpaseo/highlight": "0.1.104-beta.2",
"@getpaseo/protocol": "0.1.104-beta.2",
"@getpaseo/relay": "0.1.104-beta.2",
"@getpaseo/client": "0.1.104-beta.4",
"@getpaseo/highlight": "0.1.104-beta.4",
"@getpaseo/protocol": "0.1.104-beta.4",
"@getpaseo/relay": "0.1.104-beta.4",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -38561,7 +38582,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.104-beta.2",
"version": "0.1.104-beta.4",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.104-beta.2",
"version": "0.1.104-beta.4",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -58,7 +58,7 @@
"build:daemon-web-ui": "node scripts/build-daemon-web-ui.mjs",
"build:app-deps": "npm run build:highlight && npm run build:client && npm run build --workspace=@getpaseo/expo-two-way-audio",
"build:app-deps:clean": "npm run build:highlight:clean && npm run build:client:clean && npm run build --workspace=@getpaseo/expo-two-way-audio",
"watch:protocol": "tsc -p packages/protocol/tsconfig.json --watch --preserveWatchOutput",
"watch:protocol": "npm run watch --workspace=@getpaseo/protocol",
"watch:client": "tsc -p packages/client/tsconfig.json --watch --preserveWatchOutput",
"typecheck": "npm run typecheck --workspaces --if-present",
"typecheck:server": "npm run typecheck --workspace=@getpaseo/relay && npm run typecheck --workspace=@getpaseo/protocol && npm run typecheck --workspace=@getpaseo/client && npm run typecheck --workspace=@getpaseo/server && npm run typecheck --workspace=@getpaseo/cli",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.104-beta.2",
"version": "0.1.104-beta.4",
"private": true,
"main": "index.ts",
"scripts": {
@@ -75,6 +75,8 @@ class FakeBrowserBridge {
public readonly executedRequests: BrowserAutomationExecuteRequest[] = [];
public readonly registeredWorkspaceBrowsers: Array<{ browserId: string; workspaceId: string }> =
[];
public readonly unregisteredWorkspaceBrowsers: string[] = [];
public readonly clearedPartitions: string[] = [];
public readonly activeWorkspaceBrowsers: Array<{
browserId: string | null;
workspaceId: string;
@@ -99,6 +101,14 @@ class FakeBrowserBridge {
this.registeredWorkspaceBrowsers.push(input);
};
public unregisterWorkspaceBrowser = async (browserId: string): Promise<void> => {
this.unregisteredWorkspaceBrowsers.push(browserId);
};
public clearPartition = async (browserId: string): Promise<void> => {
this.clearedPartitions.push(browserId);
};
public setWorkspaceActiveBrowser = async (input: {
browserId: string | null;
workspaceId: string;
@@ -175,6 +185,35 @@ function browserNewTabRequest(): BrowserAutomationExecuteRequest {
};
}
function browserResizeRequest(
browserId: string,
input: { workspaceId?: string } = {},
): BrowserAutomationExecuteRequest {
return {
type: "browser.automation.execute.request",
requestId: "req-resize",
agentId: "agent-1",
workspaceId: input.workspaceId ?? "wks_workspace_a",
command: {
command: "resize",
args: { browserId, width: 1024, height: 768 },
},
};
}
function browserCloseTabRequest(browserId: string): BrowserAutomationExecuteRequest {
return {
type: "browser.automation.execute.request",
requestId: "req-close-tab",
agentId: "agent-1",
workspaceId: "wks_workspace_a",
command: {
command: "close_tab",
args: { browserId },
},
};
}
function emptyListTabsPayload(requestId = "req-new:list_tabs"): BrowserAutomationResponsePayload {
return {
requestId,
@@ -250,7 +289,7 @@ describe("mountBrowserAutomationHandler", () => {
useWorkspaceLayoutStore.setState({ layoutByWorkspace: {} });
});
test("browser_new_tab creates and focuses a workspace browser tab", async () => {
test("browser_new_tab creates a workspace browser tab without stealing focus", async () => {
const browser = new BrowserAutomationHandlerHarness();
const workspaceKey = buildWorkspaceTabPersistenceKey({
serverId: "server-1",
@@ -278,16 +317,14 @@ describe("mountBrowserAutomationHandler", () => {
expect(layout?.root).toEqual(
expect.objectContaining({
kind: "pane",
pane: expect.objectContaining({ focusedTabId: openedTabs[0]?.tabId }),
pane: expect.objectContaining({ focusedTabId: previousFocusedTabId }),
}),
);
expect(openedTabs[0]?.tabId).not.toBe(previousFocusedTabId);
expect(browser.browser.registeredWorkspaceBrowsers).toEqual([
{ browserId: result.browserId, workspaceId: "wks_workspace_a" },
]);
expect(browser.browser.activeWorkspaceBrowsers).toEqual([
{ browserId: result.browserId, workspaceId: "wks_workspace_a" },
]);
expect(browser.browser.activeWorkspaceBrowsers).toEqual([]);
expect(browser.resident.ensuredWebviews).toEqual([
{ browserId: result.browserId, url: "https://example.com" },
]);
@@ -357,6 +394,106 @@ describe("mountBrowserAutomationHandler", () => {
]);
});
test("browser_resize updates resident webview dimensions", async () => {
const browser = new BrowserAutomationHandlerHarness();
browser.mount({ serverId: "server-1" });
browser.receive(browserNewTabRequest());
await flushAsyncWork();
const result = newTabResultFrom(browser.client.payloadAt(0));
browser.receive(browserResizeRequest(result.browserId));
await flushAsyncWork();
expect(browser.client.payloadAt(1)).toEqual({
requestId: "req-resize",
ok: true,
result: {
command: "resize",
browserId: result.browserId,
width: 1024,
height: 768,
},
});
expect(browser.browser.executedRequests).toHaveLength(1);
});
test("browser_resize returns not found for a tab outside the request workspace", async () => {
const browser = new BrowserAutomationHandlerHarness();
browser.mount({ serverId: "server-1" });
browser.receive(browserNewTabRequest());
await flushAsyncWork();
const result = newTabResultFrom(browser.client.payloadAt(0));
browser.receive(browserResizeRequest(result.browserId, { workspaceId: "wks_workspace_b" }));
await flushAsyncWork();
expect(browser.client.payloadAt(1)).toEqual({
requestId: "req-resize",
ok: false,
error: {
code: "browser_tab_not_found",
message: `No browser tab found for ID: ${result.browserId}`,
retryable: false,
},
});
});
test("browser_close_tab removes the workspace tab, browser record, resident webview, registry entry, and partition", async () => {
const browser = new BrowserAutomationHandlerHarness();
const workspaceKey = buildWorkspaceTabPersistenceKey({
serverId: "server-1",
workspaceId: "wks_workspace_a",
});
if (!workspaceKey) {
throw new Error("Expected workspace key");
}
browser.mount({ serverId: "server-1" });
browser.receive(browserNewTabRequest());
await flushAsyncWork();
const result = newTabResultFrom(browser.client.payloadAt(0));
browser.receive(browserCloseTabRequest(result.browserId));
await flushAsyncWork();
expect(browser.client.payloadAt(1)).toEqual({
requestId: "req-close-tab",
ok: true,
result: { command: "close_tab", browserId: result.browserId },
});
expect(workspaceBrowserTabs(workspaceKey, result.browserId)).toEqual([]);
expect(useBrowserStore.getState().browsersById[result.browserId]).toBeUndefined();
expect(browser.browser.unregisteredWorkspaceBrowsers).toEqual([result.browserId]);
expect(browser.browser.clearedPartitions).toEqual([result.browserId]);
expect(currentBrowserTabs()).toEqual([]);
});
test("browser_close_tab returns not found after the tab is gone", async () => {
const browser = new BrowserAutomationHandlerHarness();
browser.mount({ serverId: "server-1" });
browser.receive(browserNewTabRequest());
await flushAsyncWork();
const result = newTabResultFrom(browser.client.payloadAt(0));
browser.receive(browserCloseTabRequest(result.browserId));
await flushAsyncWork();
browser.receive(browserCloseTabRequest(result.browserId));
await flushAsyncWork();
expect(browser.client.payloadAt(2)).toEqual({
requestId: "req-close-tab",
ok: false,
error: {
code: "browser_tab_not_found",
message: `No browser tab found for ID: ${result.browserId}`,
retryable: false,
},
});
});
test("non-new-tab requests send the desktop bridge response", async () => {
const browser = new BrowserAutomationHandlerHarness();
browser.browser.response = {
@@ -398,7 +535,7 @@ describe("mountBrowserAutomationHandler", () => {
ok: false,
error: {
code: "browser_unsupported",
message: "Desktop browser automation is not available in this app runtime.",
message: "Browser automation is not available in this app runtime.",
retryable: false,
},
},
@@ -452,7 +589,7 @@ describe("mountBrowserAutomationHandler", () => {
ok: false,
error: {
code: "browser_unsupported",
message: "Desktop browser automation is not implemented by this desktop build yet.",
message: "Browser automation is not implemented by this app build yet.",
retryable: false,
},
},
+160 -8
View File
@@ -1,9 +1,14 @@
import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { getDesktopHost, type DesktopHostBridge } from "@/desktop/host";
import { ensureResidentBrowserWebview as ensureResidentBrowserWebviewDefault } from "@/components/browser-webview-resident";
import { createWorkspaceBrowser } from "@/stores/browser-store";
import {
ensureResidentBrowserWebview as ensureResidentBrowserWebviewDefault,
removeResidentBrowserWebview,
resizeResidentBrowserWebview,
} from "@/components/browser-webview-resident";
import { createWorkspaceBrowser, getBrowserRecord, useBrowserStore } from "@/stores/browser-store";
import {
buildWorkspaceTabPersistenceKey,
collectAllTabs,
useWorkspaceLayoutStore,
} from "@/stores/workspace-layout-store";
@@ -114,13 +119,40 @@ async function handleBrowserAutomationRequest(params: {
return;
}
if (request.command.command === "resize") {
client.sendBrowserAutomationExecuteResponse({
type: "browser.automation.execute.response",
payload: resizeBrowserTabForRequest({ request, serverId }),
});
return;
}
if (request.command.command === "close_tab") {
try {
client.sendBrowserAutomationExecuteResponse({
type: "browser.automation.execute.response",
payload: await closeBrowserTabForRequest({
request,
serverId,
browserHost,
}),
});
} catch (error) {
client.sendBrowserAutomationExecuteResponse({
type: "browser.automation.execute.response",
payload: normalizeThrownBridgeError(request.requestId, error),
});
}
return;
}
if (!executeAutomationCommand) {
client.sendBrowserAutomationExecuteResponse({
type: "browser.automation.execute.response",
payload: browserAutomationFailure({
requestId: request.requestId,
code: "browser_unsupported",
message: "Desktop browser automation is not available in this app runtime.",
message: "Browser automation is not available in this app runtime.",
}),
});
return;
@@ -140,6 +172,127 @@ async function handleBrowserAutomationRequest(params: {
}
}
function resizeBrowserTabForRequest(params: {
request: BrowserAutomationExecuteRequest;
serverId?: string;
}): BrowserAutomationResponsePayload {
const { request, serverId } = params;
const command = request.command as Extract<
BrowserAutomationExecuteRequest["command"],
{ command: "resize" }
>;
const browserId = command.args.browserId;
if (!getBrowserRecord(browserId)) {
return browserAutomationFailure({
requestId: request.requestId,
code: "browser_tab_not_found",
message: `No browser tab found for ID: ${browserId}`,
});
}
const workspaceId = request.workspaceId;
if (serverId && workspaceId && !findWorkspaceBrowserTab({ serverId, workspaceId, browserId })) {
return browserAutomationFailure({
requestId: request.requestId,
code: "browser_tab_not_found",
message: `No browser tab found for ID: ${browserId}`,
});
}
const dimensions = resizeResidentBrowserWebview({
browserId,
width: command.args.width,
height: command.args.height,
});
if (!dimensions) {
return browserAutomationFailure({
requestId: request.requestId,
code: "browser_tab_not_found",
message: `No browser tab found for ID: ${browserId}`,
});
}
return {
requestId: request.requestId,
ok: true,
result: {
command: "resize",
browserId,
width: dimensions.width,
height: dimensions.height,
},
};
}
async function closeBrowserTabForRequest(params: {
request: BrowserAutomationExecuteRequest;
serverId?: string;
browserHost: DesktopHostBridge["browser"] | undefined;
}): Promise<BrowserAutomationResponsePayload> {
const { request, serverId, browserHost } = params;
const command = request.command as Extract<
BrowserAutomationExecuteRequest["command"],
{ command: "close_tab" }
>;
const browserId = command.args.browserId;
const workspaceId = request.workspaceId;
const workspaceTab = serverId
? findWorkspaceBrowserTab({ serverId, workspaceId, browserId })
: null;
if (!workspaceTab && (!serverId || !workspaceId)) {
return browserAutomationFailure({
requestId: request.requestId,
code: "browser_unsupported",
message: "Cannot close a browser tab without a workspace context.",
});
}
if (!workspaceTab || !getBrowserRecord(browserId)) {
return browserAutomationFailure({
requestId: request.requestId,
code: "browser_tab_not_found",
message: `No browser tab found for ID: ${browserId}`,
});
}
useWorkspaceLayoutStore.getState().closeTab(workspaceTab.workspaceKey, workspaceTab.tabId);
useBrowserStore.getState().removeBrowser(browserId);
removeResidentBrowserWebview(browserId);
await browserHost?.unregisterWorkspaceBrowser?.(browserId);
await browserHost?.clearPartition?.(browserId);
return {
requestId: request.requestId,
ok: true,
result: { command: "close_tab", browserId },
};
}
function findWorkspaceBrowserTab(input: {
serverId: string;
workspaceId: string | undefined;
browserId: string;
}): { workspaceKey: string; tabId: string } | null {
if (!input.workspaceId) {
return null;
}
const workspaceKey = buildWorkspaceTabPersistenceKey({
serverId: input.serverId,
workspaceId: input.workspaceId,
});
if (!workspaceKey) {
return null;
}
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
const tab = layout
? collectAllTabs(layout.root).find((candidate) => {
return (
candidate.target.kind === "browser" && candidate.target.browserId === input.browserId
);
})
: null;
return tab ? { workspaceKey, tabId: tab.tabId } : null;
}
async function openBrowserTabForRequest(params: {
request: BrowserAutomationExecuteRequest;
serverId?: string;
@@ -179,13 +332,12 @@ async function openBrowserTabForRequest(params: {
message: "Cannot create a browser tab without a workspace context.",
});
}
useWorkspaceLayoutStore.getState().openTabFocused(workspaceKey, {
useWorkspaceLayoutStore.getState().openTabInBackground(workspaceKey, {
kind: "browser",
browserId,
});
await browserHost?.registerWorkspaceBrowser?.({ browserId, workspaceId });
await browserHost?.setWorkspaceActiveBrowser?.({ browserId, workspaceId });
if (browserHost?.executeAutomationCommand) {
ensureResidentBrowserWebview({ browserId, url: normalizedUrl });
@@ -203,7 +355,7 @@ async function openBrowserTabForRequest(params: {
return browserAutomationFailure({
requestId: request.requestId,
code: "browser_timeout",
message: `Timed out waiting for browser tab ${browserId} to register with desktop automation. Try browser_new_tab again.`,
message: `Timed out waiting for browser tab ${browserId} to register with the browser automation host. Try browser_new_tab again.`,
retryable: true,
});
}
@@ -271,14 +423,14 @@ function normalizeThrownBridgeError(
return browserAutomationFailure({
requestId,
code: "browser_unsupported",
message: "Desktop browser automation is not implemented by this desktop build yet.",
message: "Browser automation is not implemented by this app build yet.",
});
}
return browserAutomationFailure({
requestId,
code: "browser_unknown_error",
message: message || "Desktop browser automation failed.",
message: message || "Browser automation failed.",
});
}
@@ -4,6 +4,7 @@ const RESIDENT_VIEWPORT_WIDTH = 1280;
const RESIDENT_VIEWPORT_HEIGHT = 800;
const residentWebviewsByBrowserId = new Map<string, HTMLElement>();
const residentWebviewSizesByBrowserId = new Map<string, { width: number; height: number }>();
interface BrowserWebviewElement extends HTMLElement {
src: string;
@@ -66,11 +67,24 @@ function findBrowserWebview(browserId: string, ownerDocument: Document): HTMLEle
return null;
}
function applyResidentWebviewStyle(webview: HTMLElement): void {
function dimensionsForBrowser(browserId: string | null): { width: number; height: number } {
if (!browserId) {
return { width: RESIDENT_VIEWPORT_WIDTH, height: RESIDENT_VIEWPORT_HEIGHT };
}
return (
residentWebviewSizesByBrowserId.get(browserId) ?? {
width: RESIDENT_VIEWPORT_WIDTH,
height: RESIDENT_VIEWPORT_HEIGHT,
}
);
}
function applyResidentWebviewStyle(webview: HTMLElement, browserId: string | null): void {
const dimensions = dimensionsForBrowser(browserId);
webview.style.display = "inline-flex";
webview.style.flex = "0 0 auto";
webview.style.width = `${RESIDENT_VIEWPORT_WIDTH}px`;
webview.style.height = `${RESIDENT_VIEWPORT_HEIGHT}px`;
webview.style.width = `${dimensions.width}px`;
webview.style.height = `${dimensions.height}px`;
webview.style.border = "0";
webview.style.background = "transparent";
webview.style.position = "absolute";
@@ -163,10 +177,33 @@ export function releaseResidentBrowserWebview(browserId: string, webview: HTMLEl
}
residentWebviewsByBrowserId.set(normalizedBrowserId, webview);
applyResidentWebviewStyle(webview);
applyResidentWebviewStyle(webview, normalizedBrowserId);
getResidentBrowserHost(ownerDocument).appendChild(webview);
}
export function resizeResidentBrowserWebview(input: {
browserId: string;
width: number;
height: number;
}): { width: number; height: number } | null {
const normalizedBrowserId = trimNonEmpty(input.browserId);
if (!normalizedBrowserId) {
return null;
}
const width = Math.max(1, Math.round(input.width));
const height = Math.max(1, Math.round(input.height));
residentWebviewSizesByBrowserId.set(normalizedBrowserId, { width, height });
const ownerDocument = readDocument();
const webview = ownerDocument ? findBrowserWebview(normalizedBrowserId, ownerDocument) : null;
if (webview) {
webview.style.width = `${width}px`;
webview.style.height = `${height}px`;
}
return { width, height };
}
export function removeResidentBrowserWebview(browserId: string): void {
const normalizedBrowserId = trimNonEmpty(browserId);
if (!normalizedBrowserId) {
@@ -175,6 +212,7 @@ export function removeResidentBrowserWebview(browserId: string): void {
const resident = residentWebviewsByBrowserId.get(normalizedBrowserId) ?? null;
residentWebviewsByBrowserId.delete(normalizedBrowserId);
residentWebviewSizesByBrowserId.delete(normalizedBrowserId);
resident?.remove();
}
@@ -183,5 +221,6 @@ export function clearResidentBrowserWebviewsForTests(): void {
webview.remove();
}
residentWebviewsByBrowserId.clear();
residentWebviewSizesByBrowserId.clear();
readDocument()?.getElementById(RESIDENT_BROWSER_HOST_ID)?.remove();
}
+11 -11
View File
@@ -59,19 +59,19 @@ const CATALOG_DATA = [
title: "Cline",
description:
"Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
version: "3.0.34",
version: "3.0.37",
iconId: "cline",
installLink: "https://cline.bot/cli",
command: ["npx", "-y", "cline@3.0.34", "--acp"],
command: ["npx", "-y", "cline@3.0.37", "--acp"],
},
{
id: "codebuddy-code",
title: "Codebuddy Code",
description: "Tencent Cloud's official intelligent coding tool",
version: "2.115.0",
version: "2.116.0",
iconId: "codebuddy-code",
installLink: "https://www.codebuddy.cn/cli/",
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.115.0", "--acp"],
command: ["npx", "-y", "@tencent-ai/codebuddy-code@2.116.0", "--acp"],
},
{
id: "codewhale",
@@ -140,10 +140,10 @@ const CATALOG_DATA = [
id: "dimcode",
title: "DimCode",
description: "A coding agent that puts leading models at your command.",
version: "0.2.17",
version: "0.2.19",
iconId: "dimcode",
installLink: "https://dimcode.dev/docs/acp.html",
command: ["npx", "-y", "dimcode@0.2.17", "acp"],
command: ["npx", "-y", "dimcode@0.2.19", "acp"],
},
{
id: "dirac",
@@ -302,19 +302,19 @@ const CATALOG_DATA = [
id: "qoder",
title: "Qoder CLI",
description: "AI coding assistant with agentic capabilities",
version: "1.0.36",
version: "1.0.37",
iconId: "qoder",
installLink: "https://qoder.com",
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.36", "--acp"],
command: ["npx", "-y", "@qoder-ai/qodercli@1.0.37", "--acp"],
},
{
id: "qwen-code",
title: "Qwen Code",
description: "Alibaba's Qwen coding assistant",
version: "0.19.5",
version: "0.19.6",
iconId: "qwen-code",
installLink: "https://qwenlm.github.io/qwen-code-docs/en/users/overview",
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.5", "--acp", "--experimental-skills"],
command: ["npx", "-y", "@qwen-code/qwen-code@0.19.6", "--acp", "--experimental-skills"],
},
{
id: "sigit",
@@ -341,7 +341,7 @@ const CATALOG_DATA = [
description: "ByteDance's official TRAE coding agent with native ACP support",
version: "manual",
iconId: "traecli",
installLink: "https://docs.trae.cn/cli",
installLink: "https://docs.trae.cn/cli_get-started-with-trae-cli",
command: ["traecli", "acp", "serve"],
},
{
+1
View File
@@ -128,6 +128,7 @@ export interface DesktopBrowserNewTabRequestEvent {
export interface DesktopBrowserBridge {
registerWorkspaceBrowser?: (input: { browserId: string; workspaceId: string }) => Promise<void>;
unregisterWorkspaceBrowser?: (browserId: string) => Promise<void>;
setWorkspaceActiveBrowser?: (input: {
workspaceId: string;
browserId: string | null;
+9 -3
View File
@@ -39,6 +39,7 @@ import {
} from "@/desktop/daemon/desktop-daemon-transport";
import { getDesktopHost } from "@/desktop/host";
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas";
import { replaceFetchedAgentDirectory } from "@/utils/agent-directory-sync";
import { useSessionStore } from "@/stores/session-store";
import {
@@ -522,10 +523,15 @@ function probeIntervalForConnection(
}
function createDefaultDeps(): HostRuntimeControllerDeps {
const desktopBrowserAutomationAvailable =
const browserHostAvailable =
typeof getDesktopHost()?.browser?.executeAutomationCommand === "function";
const browserAutomationCapabilities = desktopBrowserAutomationAvailable
? { [CLIENT_CAPS.desktopBrowserAutomation]: true }
const browserAutomationCapabilities = browserHostAvailable
? {
[CLIENT_CAPS.browserHost]: {
supportedCommands: [...BROWSER_AUTOMATION_COMMAND_NAMES],
hostKind: "desktop app",
},
}
: undefined;
return {
@@ -2,7 +2,7 @@ import type { MutableDaemonConfig } from "@getpaseo/protocol/messages";
export const BROWSER_TOOLS_TITLE = "Browser tools";
export const BROWSER_TOOLS_WARNING =
"Allow agents to access and control Paseo desktop browser tabs, including logged-in browser state. Only enable this for agents you trust.";
"Allow agents to access and control Paseo browser tabs, including logged-in browser state. Only enable this for agents you trust.";
export interface BrowserToolsCardState {
isVisible: boolean;
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.104-beta.2",
"version": "0.1.104-beta.4",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -27,9 +27,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.1.104-beta.2",
"@getpaseo/protocol": "0.1.104-beta.2",
"@getpaseo/server": "0.1.104-beta.2",
"@getpaseo/client": "0.1.104-beta.4",
"@getpaseo/protocol": "0.1.104-beta.4",
"@getpaseo/server": "0.1.104-beta.4",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -25,6 +25,7 @@ export interface LocalDaemonPidInfo {
hostname?: string;
uid?: number;
listen?: string;
executablePath?: string;
desktopManaged?: boolean;
}
@@ -255,6 +256,7 @@ function readPidFile(pidPath: string): LocalDaemonPidInfo | null {
hostname: typeof parsed.hostname === "string" ? parsed.hostname : undefined,
uid: typeof parsed.uid === "number" ? parsed.uid : undefined,
listen: resolveListenField(parsed.listen, parsed.sockPath),
executablePath: typeof parsed.executablePath === "string" ? parsed.executablePath : undefined,
desktopManaged: parsed.desktopManaged === true ? true : undefined,
};
} catch {
@@ -0,0 +1,99 @@
import type { Command } from "commander";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test, vi } from "vitest";
import type { CommandOptions } from "../../output/index.js";
import { runStatusCommand } from "./status.js";
const mocks = vi.hoisted(() => ({
execCommand: vi.fn(async () => ({ stdout: process.execPath, stderr: "" })),
findExecutable: vi.fn(async () => null),
getOrCreateServerId: vi.fn(() => "srv_test"),
loadConfig: vi.fn(() => ({
listen: "127.0.0.1:6767",
relayEnabled: true,
relayEndpoint: "relay.paseo.sh:443",
relayUseTls: false,
relayPublicUseTls: false,
})),
resolvePaseoHome: vi.fn((env: NodeJS.ProcessEnv) => env.PASEO_HOME ?? "/tmp/paseo"),
spawnProcess: vi.fn(),
}));
vi.mock("@getpaseo/server", () => ({
execCommand: mocks.execCommand,
findExecutable: mocks.findExecutable,
getOrCreateServerId: mocks.getOrCreateServerId,
loadConfig: mocks.loadConfig,
resolvePaseoHome: mocks.resolvePaseoHome,
spawnProcess: mocks.spawnProcess,
}));
const tempRoots: string[] = [];
async function createStatusHome(pidLock: Record<string, unknown>): Promise<string> {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-daemon-status-"));
tempRoots.push(root);
const home = path.join(root, ".paseo");
await mkdir(home, { recursive: true });
await writeFile(path.join(home, "paseo.pid"), JSON.stringify(pidLock));
return home;
}
async function readStatusJson(home: string): Promise<Record<string, unknown>> {
const previousPath = process.env.PATH;
try {
process.env.PATH = "";
const result = await runStatusCommand({ home } as CommandOptions, {} as Command);
return result.schema.serialize?.(result.data[0]) as Record<string, unknown>;
} finally {
if (previousPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = previousPath;
}
}
}
describe("daemon status desktop management fields", () => {
afterEach(async () => {
await Promise.all(
tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
);
});
test("reports executable identity without deriving desktop management in the CLI", async () => {
const executablePath =
"/Applications/Paseo.app/Contents/Frameworks/Paseo Helper.app/Contents/MacOS/Paseo Helper";
const home = await createStatusHome({
pid: process.pid,
startedAt: "2026-07-06T00:00:00.000Z",
hostname: "dev-host",
uid: 501,
listen: "/tmp/paseo-status.sock",
executablePath,
});
await expect(readStatusJson(home)).resolves.toMatchObject({
daemonExecutablePath: executablePath,
desktopManaged: false,
});
});
test("keeps reporting legacy desktopManaged locks for old readers", async () => {
const home = await createStatusHome({
pid: process.pid,
startedAt: "2026-07-06T00:00:00.000Z",
hostname: "dev-host",
uid: 501,
listen: "/tmp/paseo-status.sock",
desktopManaged: true,
});
await expect(readStatusJson(home)).resolves.toMatchObject({
daemonExecutablePath: null,
desktopManaged: true,
});
});
});
@@ -28,6 +28,7 @@ interface DaemonStatus {
owner: string | null;
logPath: string;
daemonNode: string;
daemonExecutablePath: string | null;
cliNode: string;
cliVersion: string;
daemonVersion: string | null;
@@ -126,6 +127,7 @@ function toStatusRows(status: DaemonStatus): StatusRow[] {
{ key: "Owner", value: status.owner ?? "-" },
{ key: "Logs", value: status.logPath },
{ key: "Daemon Node", value: status.daemonNode },
{ key: "PID Lock Executable", value: status.daemonExecutablePath ?? "-" },
{ key: "CLI Node", value: status.cliNode },
{ key: "CLI", value: status.cliVersion },
{ key: "Daemon Version", value: status.daemonVersion ?? "-" },
@@ -405,9 +407,11 @@ export async function runStatusCommand(
owner,
logPath: state.logPath,
daemonNode,
daemonExecutablePath: state.pidInfo?.executablePath ?? null,
cliNode,
cliVersion,
daemonVersion,
// COMPAT(desktopManagedPidLock): added in v0.1.105, remove after 2027-01-06 once locks without executablePath have aged out.
desktopManaged: state.pidInfo?.desktopManaged === true,
providers,
note,
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.1.104-beta.2",
"version": "0.1.104-beta.4",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -27,7 +27,7 @@
},
"scripts": {
"clean": "node ../../scripts/clean-package-dist.mjs",
"build": "tsc -p tsconfig.json --incremental false",
"build": "npm run build --workspace=@getpaseo/protocol && tsc -p tsconfig.json --incremental false",
"build:clean": "npm run clean && npm run build",
"prepack": "npm run build:clean",
"typecheck": "tsgo --noEmit",
@@ -35,8 +35,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.1.104-beta.2",
"@getpaseo/relay": "0.1.104-beta.2",
"@getpaseo/protocol": "0.1.104-beta.4",
"@getpaseo/relay": "0.1.104-beta.4",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -0,0 +1,74 @@
import type { AgentModelDefinition, ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import { normalizeAgentModelDefinition } from "@getpaseo/protocol/agent-types";
import type {
GetProvidersSnapshotResponseMessage,
ListProviderModelsResponseMessage,
SessionOutboundMessage,
} from "@getpaseo/protocol/messages";
type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"];
type GetProvidersSnapshotPayload = GetProvidersSnapshotResponseMessage["payload"];
type ProvidersSnapshotUpdatePayload = Extract<
SessionOutboundMessage,
{ type: "providers_snapshot_update" }
>["payload"];
// COMPAT(model-normalize): daemon normalizes at source (provider-registry) — shim covers older daemons; drop when floor >= v0.1.104
function normalizeAgentModels(
models: AgentModelDefinition[] | undefined,
): AgentModelDefinition[] | undefined {
if (!models) {
return models;
}
let changed = false;
const normalized = models.map((model) => {
const next = normalizeAgentModelDefinition(model);
changed ||= next !== model;
return next;
});
return changed ? normalized : models;
}
function normalizeProviderSnapshotEntry(entry: ProviderSnapshotEntry): ProviderSnapshotEntry {
const models = normalizeAgentModels(entry.models);
return models === entry.models ? entry : { ...entry, models };
}
function normalizeProviderSnapshotEntries(
entries: ProviderSnapshotEntry[],
): ProviderSnapshotEntry[] {
let changed = false;
const normalized = entries.map((entry) => {
const next = normalizeProviderSnapshotEntry(entry);
changed ||= next !== entry;
return next;
});
return changed ? normalized : entries;
}
export function normalizeListProviderModelsPayload(
payload: ListProviderModelsPayload,
): ListProviderModelsPayload {
const models = normalizeAgentModels(payload.models);
return models === payload.models ? payload : { ...payload, models };
}
export function normalizeProvidersSnapshotPayload<
T extends GetProvidersSnapshotPayload | ProvidersSnapshotUpdatePayload,
>(payload: T): T {
const entries = normalizeProviderSnapshotEntries(payload.entries);
return entries === payload.entries ? payload : { ...payload, entries };
}
export function normalizeProviderSnapshotUpdateMessage(
msg: SessionOutboundMessage,
): SessionOutboundMessage {
if (msg.type !== "providers_snapshot_update") {
return msg;
}
return { ...msg, payload: normalizeProvidersSnapshotPayload(msg.payload) };
}
+22 -5
View File
@@ -2,6 +2,7 @@ import { afterEach, expect, expectTypeOf, test, vi } from "vitest";
import { z } from "zod";
import { DaemonClient, type DaemonTransport, type Logger } from "./daemon-client";
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas";
import {
decodeFileTransferFrame,
encodeFileTransferFrame,
@@ -164,7 +165,7 @@ test("does not infer browser automation capabilities from Electron runtime", asy
capabilities: z.record(z.unknown()),
})
.parse(JSON.parse(assertStr(mock.sent[0])));
expect(hello.capabilities[CLIENT_CAPS.desktopBrowserAutomation]).toBeUndefined();
expect(hello.capabilities[CLIENT_CAPS.browserHost]).toBeUndefined();
});
test("advertises consumer-provided browser automation capabilities", async () => {
@@ -174,7 +175,12 @@ test("advertises consumer-provided browser automation capabilities", async () =>
clientId: "browser_capability_unit_test",
transportFactory: () => mock.transport,
reconnect: { enabled: false },
capabilities: { [CLIENT_CAPS.desktopBrowserAutomation]: true },
capabilities: {
[CLIENT_CAPS.browserHost]: {
supportedCommands: [...BROWSER_AUTOMATION_COMMAND_NAMES],
hostKind: "desktop app",
},
},
});
clients.push(client);
@@ -188,7 +194,10 @@ test("advertises consumer-provided browser automation capabilities", async () =>
capabilities: z.record(z.unknown()),
})
.parse(JSON.parse(assertStr(mock.sent[0])));
expect(hello.capabilities[CLIENT_CAPS.desktopBrowserAutomation]).toBe(true);
expect(hello.capabilities[CLIENT_CAPS.browserHost]).toEqual({
supportedCommands: [...BROWSER_AUTOMATION_COMMAND_NAMES],
hostKind: "desktop app",
});
});
const noopLogger: Logger = {
@@ -506,7 +515,12 @@ test("advertises client capabilities in hello", async () => {
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
capabilities: { desktop_browser_automation: true },
capabilities: {
browser_host: {
supportedCommands: ["list_tabs"],
hostKind: "desktop app",
},
},
});
clients.push(client);
@@ -524,7 +538,10 @@ test("advertises client capabilities in hello", async () => {
custom_mode_icons: true,
reasoning_merge_enum: true,
terminal_reflowable_snapshot: true,
desktop_browser_automation: true,
browser_host: {
supportedCommands: ["list_tabs"],
hostKind: "desktop app",
},
},
});
});
+24 -14
View File
@@ -13,8 +13,8 @@ import {
DaemonUpdateResponseSchema,
SessionInboundMessageSchema,
type ServerInfoStatusPayload,
WSOutboundMessageSchema,
} from "@getpaseo/protocol/messages";
import { validateWSOutboundMessage } from "@getpaseo/protocol/validation/ws-outbound";
import type {
AgentStreamEventPayload,
AgentSnapshotPayload,
@@ -118,6 +118,11 @@ import {
type WebSocketFactory,
} from "./daemon-client-transport.js";
import { DaemonClientRuntimeMetrics } from "./daemon-client-runtime-metrics.js";
import {
normalizeListProviderModelsPayload,
normalizeProviderSnapshotUpdateMessage,
normalizeProvidersSnapshotPayload,
} from "./compat/normalize-provider-models.js";
import { TerminalStreamRouter, type TerminalStreamEvent } from "./terminal-stream-router.js";
import type {
BrowserAutomationExecuteRequest,
@@ -252,7 +257,7 @@ export interface DaemonClientConfig {
};
runtimeMetricsIntervalMs?: number;
runtimeMetricsWindowMs?: number;
capabilities?: Partial<Record<ClientCapability, boolean>>;
capabilities?: Partial<Record<ClientCapability, unknown>>;
}
export interface SendMessageOptions {
@@ -370,6 +375,7 @@ type WriteProjectConfigPayload = Extract<
SessionOutboundMessage,
{ type: "write_project_config_response" }
>["payload"];
type ListCommandsPayload = ListCommandsResponse["payload"];
type ListCommandsDraftConfig = Pick<
AgentSessionConfig,
@@ -3728,7 +3734,7 @@ export class DaemonClient {
provider: AgentProvider,
options?: { cwd?: string; requestId?: string },
): Promise<ListProviderModelsPayload> {
return this.sendCorrelatedSessionRequest({
const payload = await this.sendCorrelatedSessionRequest({
requestId: options?.requestId,
message: {
type: "list_provider_models_request",
@@ -3739,6 +3745,7 @@ export class DaemonClient {
// Provider SDK cold starts (especially model discovery) can exceed 60s.
timeout: 90000,
});
return normalizeListProviderModelsPayload(payload);
}
async listProviderModes(
@@ -3788,7 +3795,7 @@ export class DaemonClient {
cwd?: string;
requestId?: string;
}): Promise<GetProvidersSnapshotPayload> {
return this.sendCorrelatedSessionRequest({
const payload = await this.sendCorrelatedSessionRequest({
requestId: options?.requestId,
message: {
type: "get_providers_snapshot_request",
@@ -3796,6 +3803,7 @@ export class DaemonClient {
},
responseType: "get_providers_snapshot_response",
});
return normalizeProvidersSnapshotPayload(payload);
}
async getDaemonConfig(
@@ -4754,7 +4762,7 @@ export class DaemonClient {
return;
}
const parsed = WSOutboundMessageSchema.safeParse(parsedJson);
const parsed = validateWSOutboundMessage(parsedJson);
if (!parsed.success) {
const msgType =
parsedJson != null &&
@@ -5017,8 +5025,10 @@ export class DaemonClient {
}
private handleSessionMessage(msg: SessionOutboundMessage): void {
if (msg.type === "status") {
const serverInfo = parseServerInfoStatusPayload(msg.payload);
const consumerMessage = normalizeProviderSnapshotUpdateMessage(msg);
if (consumerMessage.type === "status") {
const serverInfo = parseServerInfoStatusPayload(consumerMessage.payload);
if (serverInfo) {
this.lastServerInfoMessage = serverInfo;
if (this.connectionState.status === "connecting") {
@@ -5034,39 +5044,39 @@ export class DaemonClient {
}
}
if (msg.type === "terminal_stream_exit") {
this.terminalStreams.removeTerminal(msg.payload.terminalId);
if (consumerMessage.type === "terminal_stream_exit") {
this.terminalStreams.removeTerminal(consumerMessage.payload.terminalId);
}
if (this.rawMessageListeners.size > 0) {
for (const handler of this.rawMessageListeners) {
try {
handler(msg);
handler(consumerMessage);
} catch {
// no-op
}
}
}
const handlers = this.messageHandlers.get(msg.type);
const handlers = this.messageHandlers.get(consumerMessage.type);
if (handlers) {
for (const handler of handlers) {
try {
handler(msg);
handler(consumerMessage);
} catch {
// no-op
}
}
}
const event = this.toEvent(msg);
const event = this.toEvent(consumerMessage);
if (event) {
for (const handler of this.eventListeners) {
handler(event);
}
}
this.resolveWaiters(msg);
this.resolveWaiters(consumerMessage);
}
private resolveWaiters(msg: SessionOutboundMessage): void {
+18 -1
View File
@@ -629,20 +629,37 @@ test("provider actions delegate to existing provider RPCs and local snapshot upd
});
const snapshotUpdates: string[] = [];
const snapshotModelDefaults: Array<string | undefined> = [];
const unsubscribe = client.providers.subscribe((update) => {
snapshotUpdates.push(update.generatedAt);
snapshotModelDefaults.push(update.entries[0]?.models?.[0]?.defaultThinkingOptionId);
});
ws.message(
sessionMessage({
type: "providers_snapshot_update",
payload: {
cwd: "/repo/sdk",
entries: [{ provider: "codex", status: "ready", enabled: true }],
entries: [
{
provider: "codex",
status: "ready",
enabled: true,
models: [
{
provider: "codex",
id: "gpt-5.5",
label: "GPT-5.5",
thinkingOptions: [{ id: "high", label: "High", isDefault: true }],
},
],
},
],
generatedAt: "2026-05-16T01:00:00.000Z",
},
}),
);
expect(snapshotUpdates).toEqual(["2026-05-16T01:00:00.000Z"]);
expect(snapshotModelDefaults).toEqual(["high"]);
unsubscribe();
await client.close();
+812 -1
View File
@@ -1095,12 +1095,823 @@ async function runPermanentParkingGroup() {
return results;
}
function automationFixtureUrl() {
const html = `<!doctype html>
<html>
<head><meta charset="utf-8"><title>Automation Fixture</title></head>
<style>
body { min-height: 1800px; }
#hover-target { display: none; }
#hover-source:hover + #hover-target { display: inline-block; }
#moving {
animation: slide 320ms linear;
}
#drag-target {
display: inline-block;
margin-left: 24px;
padding: 12px;
border: 1px solid #888;
}
@keyframes slide {
from { transform: translateX(80px); }
to { transform: translateX(0); }
}
</style>
<body>
<main>
<h1>Settings</h1>
<section aria-label="Account">
<p>Connected as Maya</p>
<label for="name">Name</label>
<input id="name" value="Maya">
<a href="#docs">Read docs</a>
<button id="save">Save changes</button>
<button id="delayed" disabled>Delayed save</button>
<button id="moving">Moving target</button>
<button id="hover-source">Reveal actions</button>
<button id="hover-target">Revealed action</button>
<button id="drag-source">Drag source</button>
<span id="drag-target">Drop target</span>
<button id="dialog-alert">Open alert</button>
<button id="dialog-confirm">Open confirm</button>
<button id="dialog-prompt">Open prompt</button>
<button id="dialog-beforeunload">Arm beforeunload</button>
<input id="upload" type="file" aria-label="Upload receipt">
</section>
</main>
<script>
window.fixtureLog = [];
document.getElementById("save").addEventListener("click", (event) => {
window.fixtureLog.push({
event: "click-save",
trusted: event.isTrusted,
button: event.button,
detail: event.detail,
ctrlKey: event.ctrlKey,
shiftKey: event.shiftKey,
});
});
document.getElementById("save").addEventListener("mousedown", (event) => {
window.fixtureLog.push({
event: "down-save",
trusted: event.isTrusted,
button: event.button,
detail: event.detail,
ctrlKey: event.ctrlKey,
shiftKey: event.shiftKey,
});
});
document.getElementById("name").addEventListener("input", (event) => {
window.fixtureLog.push({ event: "input-name", trusted: event.isTrusted });
});
document.getElementById("delayed").addEventListener("click", (event) => {
window.fixtureLog.push({ event: "click-delayed", trusted: event.isTrusted });
});
document.getElementById("moving").addEventListener("click", (event) => {
window.fixtureLog.push({ event: "click-moving", trusted: event.isTrusted });
});
document.getElementById("drag-source").addEventListener("pointerdown", (event) => {
window.fixtureLog.push({ event: "drag-down", trusted: event.isTrusted });
});
document.getElementById("drag-target").addEventListener("pointerup", (event) => {
window.fixtureLog.push({ event: "drag-up", trusted: event.isTrusted });
});
document.getElementById("dialog-alert").addEventListener("click", () => {
alert("Alert opened");
window.fixtureLog.push({ event: "alert-returned" });
});
document.getElementById("dialog-confirm").addEventListener("click", () => {
window.fixtureLog.push({ event: "confirm-result", value: confirm("Confirm action?") });
});
document.getElementById("dialog-prompt").addEventListener("click", () => {
window.fixtureLog.push({ event: "prompt-result", value: prompt("Prompt value?", "Maya") });
});
window.beforeUnloadEnabled = false;
document.getElementById("dialog-beforeunload").addEventListener("click", () => {
window.beforeUnloadEnabled = true;
window.fixtureLog.push({ event: "beforeunload-armed" });
});
window.addEventListener("beforeunload", (event) => {
if (!window.beforeUnloadEnabled) return;
event.preventDefault();
event.returnValue = "Leave fixture?";
});
setTimeout(() => {
document.getElementById("delayed").disabled = false;
}, 250);
window.pushFixtureState = () => history.pushState({}, "", "#advanced");
window.sameUrlRerender = () => {
const oldSave = document.getElementById("save");
const nextSave = document.createElement("button");
nextSave.id = "save";
nextSave.textContent = "Save later";
oldSave.replaceWith(nextSave);
};
</script>
</body>
</html>`;
return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
}
const AUTOMATION_SNAPSHOT_PROBE = String.raw`(() => {
const refs = new Map();
const lines = ['- document "Automation Fixture"'];
let nextRef = 1;
function text(value) {
return String(value || '').replace(/\s+/g, ' ').trim();
}
function fingerprint(element, role, name) {
return { role, name, tagName: element.tagName.toLowerCase(), type: element.getAttribute('type') || '', ariaLabel: element.getAttribute('aria-label') || '' };
}
function runtime() {
const api = {
refs,
resolve(ref, expected) {
const element = refs.get(ref);
if (!element || !element.isConnected) return { ok: false, reason: 'stale_ref' };
const role = roleFor(element);
const name = nameFor(element, role);
const current = fingerprint(element, role, name);
return current.role === expected.role && current.name === expected.name && current.tagName === expected.tagName && current.type === expected.type && current.ariaLabel === expected.ariaLabel
? { ok: true, element }
: { ok: false, reason: 'stale_ref' };
}
};
Object.defineProperty(window, '__PASEO_BROWSER_AUTOMATION__', { configurable: true, value: api });
return api;
}
function roleFor(element) {
const tag = element.tagName.toLowerCase();
if (/^h[1-6]$/.test(tag)) return 'heading';
if (tag === 'input') return element.type === 'file' ? 'button' : 'textbox';
if (tag === 'button') return 'button';
if (element.id === 'drag-target') return 'button';
if (tag === 'a') return 'link';
if (tag === 'section') return 'region';
return '';
}
function nameFor(element, role) {
if (element.getAttribute('aria-label')) return element.getAttribute('aria-label');
if (element.id) {
const label = document.querySelector('label[for="' + element.id + '"]');
if (label) return text(label.textContent);
}
return role === 'textbox' ? text(element.value || element.placeholder) : text(element.textContent);
}
const api = runtime();
const heading = document.querySelector('h1');
lines.push(' - heading "' + nameFor(heading, 'heading') + '" [level=1]');
lines.push(' - text: "Connected as Maya"');
for (const element of document.querySelectorAll('input, a, button, #drag-target')) {
const role = roleFor(element);
const name = nameFor(element, role);
const ref = '@e' + nextRef++;
const fp = fingerprint(element, role, name);
api.refs.set(ref, element);
lines.push(' - ' + role + ' "' + name + '" [ref=' + ref + ']');
}
return {
snapshot: lines.join('\n'),
refs: Array.from(api.refs.entries()).map(([ref, element]) => {
const role = roleFor(element);
return { ref, fingerprint: fingerprint(element, role, nameFor(element, role)) };
})
};
})()`;
async function attachAutomationDebugger(guest) {
if (!guest.debugger.isAttached()) {
guest.debugger.attach("1.3");
}
return (command, params = {}) => guest.debugger.sendCommand(command, params);
}
async function automationRefPoint(guest, ref, fingerprint) {
const result = await guest.executeJavaScript(
String.raw`(async () => {
const fingerprint = ${JSON.stringify(fingerprint)};
const ref = ${JSON.stringify(ref)};
const deadline = performance.now() + 5000;
const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve));
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const sameRect = (a, b) =>
Math.abs(a.x - b.x) < 0.25 &&
Math.abs(a.y - b.y) < 0.25 &&
Math.abs(a.width - b.width) < 0.25 &&
Math.abs(a.height - b.height) < 0.25;
const isDisabled = (element) =>
Boolean(element.closest?.('[aria-disabled="true"]')) ||
Boolean('disabled' in element && element.disabled);
const isVisible = (element, rect) => {
const style = getComputedStyle(element);
return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none';
};
while (performance.now() <= deadline) {
const resolved = window.__PASEO_BROWSER_AUTOMATION__.resolve(ref, fingerprint);
if (!resolved.ok || !resolved.element?.isConnected) return { ok: false, reason: 'stale_ref' };
const element = resolved.element;
const rect = element.getBoundingClientRect();
if (!isVisible(element, rect) || isDisabled(element)) {
await sleep(25);
continue;
}
element.scrollIntoView?.({ block: 'center', inline: 'center' });
await nextFrame();
const first = element.getBoundingClientRect();
await nextFrame();
const second = element.getBoundingClientRect();
if (!sameRect(first, second)) continue;
const x = Math.min(Math.max(second.left + second.width / 2, 0), Math.max(window.innerWidth - 1, 0));
const y = Math.min(Math.max(second.top + second.height / 2, 0), Math.max(window.innerHeight - 1, 0));
const hit = document.elementFromPoint(x, y);
if (hit && (hit === element || element.contains(hit))) return { ok: true, x, y };
await sleep(25);
}
return { ok: false, reason: 'timeout' };
})()`,
true,
);
if (!result || result.ok !== true) {
fail(`automation ref ${ref} was not actionable: ${JSON.stringify(result)}`);
}
return { x: result.x, y: result.y };
}
async function automationClick(guest, refEntry, options = {}) {
const send = await attachAutomationDebugger(guest);
const point = await automationRefPoint(guest, refEntry.ref, refEntry.fingerprint);
const button = options.button || "left";
const modifiers = automationModifierMask(options.modifiers || []);
const clickCount = options.doubleClick ? 2 : 1;
await send("Input.dispatchMouseEvent", {
type: "mouseMoved",
x: point.x,
y: point.y,
button: "none",
modifiers,
});
await send("Input.dispatchMouseEvent", {
type: "mousePressed",
x: point.x,
y: point.y,
button,
buttons: automationButtonMask(button),
clickCount,
modifiers,
});
await send("Input.dispatchMouseEvent", {
type: "mouseReleased",
x: point.x,
y: point.y,
button,
buttons: 0,
clickCount,
modifiers,
});
return point;
}
async function automationHover(guest, refEntry) {
const send = await attachAutomationDebugger(guest);
const point = await automationRefPoint(guest, refEntry.ref, refEntry.fingerprint);
await send("Input.dispatchMouseEvent", {
type: "mouseMoved",
x: point.x,
y: point.y,
button: "none",
});
}
async function automationScroll(guest, deltaX, deltaY) {
const send = await attachAutomationDebugger(guest);
const point = await guest.executeJavaScript(
"({ x: Math.max(0, (window.innerWidth || 1) / 2), y: Math.max(0, (window.innerHeight || 1) / 2) })",
true,
);
await send("Input.dispatchMouseEvent", {
type: "mouseWheel",
x: point.x,
y: point.y,
deltaX,
deltaY,
});
}
async function automationDrag(guest, sourceRef, targetRef) {
const send = await attachAutomationDebugger(guest);
const source = await automationRefPoint(guest, sourceRef.ref, sourceRef.fingerprint);
const target = await automationRefPoint(guest, targetRef.ref, targetRef.fingerprint);
await send("Input.dispatchMouseEvent", {
type: "mouseMoved",
x: source.x,
y: source.y,
button: "none",
});
await send("Input.dispatchMouseEvent", {
type: "mousePressed",
x: source.x,
y: source.y,
button: "left",
buttons: 1,
clickCount: 1,
});
await send("Input.dispatchMouseEvent", {
type: "mouseMoved",
x: target.x,
y: target.y,
button: "left",
buttons: 1,
});
await send("Input.dispatchMouseEvent", {
type: "mouseReleased",
x: target.x,
y: target.y,
button: "left",
buttons: 0,
clickCount: 1,
});
}
async function automationType(guest, refEntry, text) {
const send = await attachAutomationDebugger(guest);
await automationClick(guest, refEntry);
await send("Input.insertText", { text });
}
async function automationEvaluate(guest, functionSource, refEntry) {
return guest.executeJavaScript(
String.raw`(async () => {
const userFunction = (0, eval)(${JSON.stringify(`(${functionSource})`)});
const args = [];
${
refEntry
? `const resolved = window.__PASEO_BROWSER_AUTOMATION__.resolve(${JSON.stringify(refEntry.ref)}, ${JSON.stringify(refEntry.fingerprint)});
if (!resolved.ok) return { ok: false, reason: "stale_ref" };
args.push(resolved.element);`
: ""
}
return JSON.stringify(await userFunction(...args));
})()`,
true,
);
}
const AUTOMATION_DIALOG_POLICY = {
alert: { action: "accepted", accept: true },
confirm: { action: "dismissed", accept: false },
prompt: { action: "dismissed", accept: false },
beforeunload: { action: "dismissed", accept: false },
};
const AUTOMATION_PROMPT_SHIM_INSTALL = String.raw`(() => {
const stateKey = "__PASEO_BROWSER_AUTOMATION_DIALOG_STATE__";
const state = window[stateKey] || { prompts: [], installed: false };
window[stateKey] = state;
if (state.installed) return true;
window.prompt = (message = "", defaultValue = "") => {
state.prompts.push({
type: "prompt",
message: String(message ?? ""),
defaultValue: String(defaultValue ?? ""),
action: "dismissed",
timestamp: Date.now(),
});
return null;
};
state.installed = true;
return true;
})()`;
const AUTOMATION_PROMPT_SHIM_DRAIN = String.raw`(() => {
const state = window.__PASEO_BROWSER_AUTOMATION_DIALOG_STATE__;
if (!state || !Array.isArray(state.prompts)) return [];
return state.prompts.splice(0);
})()`;
async function captureAutomationDialogs(guest, action, expectedCount) {
const send = await attachAutomationDebugger(guest);
await send("Page.enable");
await send("Runtime.evaluate", {
expression: AUTOMATION_PROMPT_SHIM_INSTALL,
returnByValue: true,
});
const dialogs = [];
const listener = (_event, method, params = {}) => {
if (method !== "Page.javascriptDialogOpening") return;
const type = AUTOMATION_DIALOG_POLICY[params.type] ? params.type : "alert";
const policy = AUTOMATION_DIALOG_POLICY[type];
dialogs.push({
type,
message: String(params.message || ""),
...(typeof params.defaultPrompt === "string" ? { defaultValue: params.defaultPrompt } : {}),
action: policy.action,
timestamp: Date.now(),
});
void send("Page.handleJavaScriptDialog", { accept: policy.accept });
};
guest.debugger.on("message", listener);
try {
await action();
const promptResult = await send("Runtime.evaluate", {
expression: AUTOMATION_PROMPT_SHIM_DRAIN,
returnByValue: true,
});
if (Array.isArray(promptResult.result?.value)) {
dialogs.push(...promptResult.result.value);
}
await waitForDialogCount(dialogs, expectedCount);
return dialogs;
} finally {
guest.debugger.removeListener?.("message", listener);
}
}
async function waitForDialogCount(dialogs, expectedCount) {
const deadline = Date.now() + 1000;
do {
if (dialogs.length >= expectedCount) return;
await delay(25);
} while (Date.now() < deadline);
fail(`automation observed ${dialogs.length}/${expectedCount} dialogs`);
}
function assertAutomationDialog(dialogs, expected) {
const dialog = dialogs.find((entry) => entry.type === expected.type);
if (!dialog) {
fail(`automation did not report ${expected.type} dialog: ${JSON.stringify(dialogs)}`);
}
for (const [key, value] of Object.entries(expected)) {
if (dialog[key] !== value) {
fail(`automation ${expected.type} dialog ${key}=${JSON.stringify(dialog[key])}`);
}
}
}
function automationButtonMask(button) {
if (button === "right") return 2;
if (button === "middle") return 4;
return 1;
}
function automationModifierMask(modifiers) {
const masks = { Alt: 1, Control: 2, Meta: 4, Shift: 8 };
return modifiers.reduce((mask, modifier) => mask | masks[modifier], 0);
}
function automationRefByName(snapshot, name) {
const entry = snapshot.refs.find((ref) => ref.fingerprint.name === name);
if (!entry) {
fail(`automation ref missing for ${name}`);
}
return entry;
}
async function automationLog(guest) {
return guest.executeJavaScript("window.fixtureLog", true);
}
async function waitForAutomationLog(guest, predicate, label) {
const deadline = Date.now() + 1000;
do {
const log = await automationLog(guest);
if (Array.isArray(log) && log.some(predicate)) {
return log;
}
await delay(25);
} while (Date.now() < deadline);
fail(`automation log never observed ${label}`);
}
async function runAutomationGroup() {
const results = [];
const handle = createInactiveHarnessWindow({
width: 1000,
height: 700,
backgroundColor: "#202020",
webPreferences: {
webviewTag: true,
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
},
});
const { win } = handle;
installHarnessWebviewGuards(win);
const tracker = trackAttachedGuests(win, { disableGuestBackgroundThrottlingAtAttach: true });
try {
await withTimeout(
win.loadFile(path.join(ROOT, "index.html"), {
query: {
webviewCount: "0",
permanentParkingState: "p1-overflow-1x1",
targetUrl: automationFixtureUrl(),
},
}),
"automation harness window loadFile",
);
await waitForInactiveReveal(handle, "automation harness window");
const { guest } = await appendPermanentWebview({
win,
tracker,
state: { id: "p1-overflow-1x1" },
sourceUrl: automationFixtureUrl(),
});
await waitForGuestLoad(guest);
const first = await guest.executeJavaScript(AUTOMATION_SNAPSHOT_PROBE, true);
assertAutomationSnapshot(first);
pass("automation snapshot renders headings static text controls and refs");
results.push({ group: "automation", check: "snapshot", pass: true });
const saveRef = first.refs.find((ref) => ref.fingerprint.name === "Save changes");
if (!saveRef) {
fail("automation save ref missing");
}
await guest.executeJavaScript("window.pushFixtureState()", true);
const pushStateResult = await guest.executeJavaScript(
`window.__PASEO_BROWSER_AUTOMATION__.resolve(${JSON.stringify(saveRef.ref)}, ${JSON.stringify(saveRef.fingerprint)}).ok`,
true,
);
if (pushStateResult !== true) {
fail("automation ref did not survive pushState");
}
pass("automation ref resolves after pushState");
results.push({ group: "automation", check: "pushState-ref", pass: true });
const pageEvaluate = await automationEvaluate(guest, "() => ({ title: document.title })");
const refEvaluate = await automationEvaluate(
guest,
"(element) => ({ text: element.textContent.trim(), tag: element.tagName.toLowerCase() })",
saveRef,
);
if (
pageEvaluate !== '{"title":"Automation Fixture"}' ||
refEvaluate !== '{"text":"Save changes","tag":"button"}'
) {
fail(`automation evaluate returned page=${pageEvaluate} ref=${refEvaluate}`);
}
pass("automation evaluate runs in page context and receives ref element");
results.push({ group: "automation", check: "evaluate", pass: true });
await guest.executeJavaScript("window.scrollTo(0, 0)", true);
await automationScroll(guest, 0, 500);
await delay(100);
const scrollY = await guest.executeJavaScript("window.scrollY", true);
if (!(scrollY > 0)) {
fail(`automation scroll did not change window.scrollY: ${scrollY}`);
}
pass("automation scroll changes the viewport scroll position");
results.push({ group: "automation", check: "scroll", pass: true });
const nameRef = automationRefByName(first, "Name");
await automationType(guest, nameRef, " Ada");
await automationClick(guest, saveRef);
await waitForAutomationLog(
guest,
(entry) => entry.event === "input-name" && entry.trusted === true,
"trusted input event",
);
await waitForAutomationLog(
guest,
(entry) => entry.event === "click-save" && entry.trusted === true,
"trusted click event",
);
pass("automation trusted click and text input events reach the page");
results.push({ group: "automation", check: "trusted-input", pass: true });
const hoverRef = automationRefByName(first, "Reveal actions");
await automationHover(guest, hoverRef);
const hoverVisible = await guest.executeJavaScript(
"getComputedStyle(document.getElementById('hover-target')).display !== 'none'",
true,
);
if (hoverVisible !== true) {
fail("automation hover did not reveal the hover target");
}
pass("automation hover reveals CSS :hover content");
results.push({ group: "automation", check: "hover-reveal", pass: true });
const delayedRef = automationRefByName(first, "Delayed save");
await automationClick(guest, delayedRef);
const delayedLog = await automationLog(guest);
if (!delayedLog.some((entry) => entry.event === "click-delayed" && entry.trusted === true)) {
fail("automation delayed enabled button did not receive trusted click");
}
pass("automation action waits for delayed enabled state");
results.push({ group: "automation", check: "delayed-enable", pass: true });
const movingRef = automationRefByName(first, "Moving target");
await automationClick(guest, movingRef);
const movingLog = await automationLog(guest);
if (!movingLog.some((entry) => entry.event === "click-moving" && entry.trusted === true)) {
fail("automation moving button did not receive trusted click");
}
pass("automation action waits for moving element stabilization");
results.push({ group: "automation", check: "moving-stable", pass: true });
const dragSourceRef = automationRefByName(first, "Drag source");
const dragTargetRef = automationRefByName(first, "Drop target");
await automationDrag(guest, dragSourceRef, dragTargetRef);
const dragLog = await automationLog(guest);
if (
!dragLog.some((entry) => entry.event === "drag-down" && entry.trusted === true) ||
!dragLog.some((entry) => entry.event === "drag-up" && entry.trusted === true)
) {
fail("automation pointer drag did not produce trusted pointer events");
}
pass("automation pointer-event drag reaches source and target");
results.push({ group: "automation", check: "pointer-drag", pass: true });
await automationClick(guest, saveRef, {
button: "right",
doubleClick: true,
modifiers: ["Control", "Shift"],
});
const optionsLog = await automationLog(guest);
if (
!optionsLog.some(
(entry) =>
entry.event === "down-save" &&
entry.trusted === true &&
entry.button === 2 &&
entry.detail === 2 &&
entry.ctrlKey === true &&
entry.shiftKey === true,
)
) {
fail("automation click options did not affect button/modifiers/double-click count");
}
pass("automation click options affect button modifiers and double-click count");
results.push({ group: "automation", check: "click-options", pass: true });
const alertRef = automationRefByName(first, "Open alert");
const alertDialogs = await captureAutomationDialogs(
guest,
() => automationClick(guest, alertRef),
1,
);
assertAutomationDialog(alertDialogs, {
type: "alert",
message: "Alert opened",
action: "accepted",
});
await waitForAutomationLog(guest, (entry) => entry.event === "alert-returned", "alert return");
pass("automation alert dialogs are accepted and reported");
results.push({ group: "automation", check: "dialog-alert", pass: true });
const confirmRef = automationRefByName(first, "Open confirm");
const confirmDialogs = await captureAutomationDialogs(
guest,
() => automationClick(guest, confirmRef),
1,
);
assertAutomationDialog(confirmDialogs, {
type: "confirm",
message: "Confirm action?",
action: "dismissed",
});
await waitForAutomationLog(
guest,
(entry) => entry.event === "confirm-result" && entry.value === false,
"confirm dismissal",
);
pass("automation confirm dialogs are dismissed and reported");
results.push({ group: "automation", check: "dialog-confirm", pass: true });
const promptRef = automationRefByName(first, "Open prompt");
const promptDialogs = await captureAutomationDialogs(
guest,
() => automationClick(guest, promptRef),
1,
);
assertAutomationDialog(promptDialogs, {
type: "prompt",
message: "Prompt value?",
defaultValue: "Maya",
action: "dismissed",
});
await waitForAutomationLog(
guest,
(entry) => entry.event === "prompt-result" && entry.value === null,
"prompt dismissal",
);
pass("automation prompt dialogs are dismissed and reported");
results.push({ group: "automation", check: "dialog-prompt", pass: true });
const beforeUnloadRef = automationRefByName(first, "Arm beforeunload");
await automationClick(guest, beforeUnloadRef);
const beforeUrl = guest.getURL();
const beforeUnloadDialogs = await captureAutomationDialogs(
guest,
async () => {
await guest.loadURL(automationFixtureUrl()).catch(() => {});
},
1,
);
assertAutomationDialog(beforeUnloadDialogs, {
type: "beforeunload",
action: "dismissed",
});
if (guest.getURL() !== beforeUrl) {
fail("automation beforeunload dismissal did not cancel navigation");
}
pass("automation beforeunload dialogs are dismissed and reported");
results.push({ group: "automation", check: "dialog-beforeunload", pass: true });
await guest.executeJavaScript("window.sameUrlRerender()", true);
const rerenderResult = await guest.executeJavaScript(
`window.__PASEO_BROWSER_AUTOMATION__.resolve(${JSON.stringify(saveRef.ref)}, ${JSON.stringify(saveRef.fingerprint)}).reason`,
true,
);
if (rerenderResult !== "stale_ref") {
fail(`automation same-url rerender returned ${rerenderResult}`);
}
pass("automation same-url rerender stales old ref");
results.push({ group: "automation", check: "same-url-stale-ref", pass: true });
const second = await guest.executeJavaScript(AUTOMATION_SNAPSHOT_PROBE, true);
const uploadRef = second.refs.find((ref) => ref.fingerprint.name === "Upload receipt");
if (!uploadRef) {
fail("automation upload ref missing");
}
if (!guest.debugger.isAttached()) {
guest.debugger.attach("1.3");
}
const evaluated = await guest.debugger.sendCommand("Runtime.evaluate", {
expression: `(() => window.__PASEO_BROWSER_AUTOMATION__.resolve(${JSON.stringify(uploadRef.ref)}, ${JSON.stringify(uploadRef.fingerprint)}).element)()`,
objectGroup: "paseo-browser-automation",
returnByValue: false,
});
const described = await guest.debugger.sendCommand("DOM.describeNode", {
objectId: evaluated.result.objectId,
});
if (!described.node || typeof described.node.backendNodeId !== "number") {
fail("automation upload ref did not resolve to backendNodeId");
}
pass("automation upload ref resolves to backendNodeId");
results.push({ group: "automation", check: "upload-backend-node", pass: true });
// Resize is not harness-testable: the harness hosts webviews in the parked
// 1px resident host, and Electron does not propagate CSS-box resizes to a
// parked guest's capture surface (see docs/browser-capture-harness.md).
// The production resize path is app-owned webview sizing, covered by
// packages/app/src/browser-automation/handler.test.ts.
await fsp.writeFile(
path.join(OUT_DIR, "automation-results.json"),
`${JSON.stringify({ generatedAt: new Date().toISOString(), results }, null, 2)}\n`,
);
return results;
} finally {
await closeHarnessWindow(win);
}
}
function assertAutomationSnapshot(snapshot) {
const text = snapshot && snapshot.snapshot;
if (typeof text !== "string") {
fail("automation snapshot returned no text");
}
for (const expected of [
'heading "Settings"',
'text: "Connected as Maya"',
'textbox "Name" [ref=@e1]',
'link "Read docs"',
'button "Save changes"',
'button "Upload receipt"',
]) {
if (!text.includes(expected)) {
fail(`automation snapshot missing ${expected}`);
}
}
if (text.includes("selector")) {
fail("automation snapshot exposed selector text");
}
}
async function main() {
ensureDirSync(OUT_DIR);
if (!["all", "existing", "permanent-parking"].includes(HARNESS_GROUP)) {
if (!["all", "existing", "permanent-parking", "automation"].includes(HARNESS_GROUP)) {
fail(`unknown harness group ${HARNESS_GROUP}`);
}
if (HARNESS_GROUP === "automation") {
const automationResults = await runAutomationGroup();
await fsp.writeFile(
path.join(OUT_DIR, "results.json"),
`${JSON.stringify(
{
generatedAt: new Date().toISOString(),
automationResults,
},
null,
2,
)}\n`,
);
pass(`capture harness automation complete output=${OUT_DIR}`);
return;
}
if (HARNESS_GROUP === "permanent-parking") {
const permanentParkingResults = await runPermanentParkingGroup();
await fsp.writeFile(
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.104-beta.2",
"version": "0.1.104-beta.4",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
@@ -7,6 +7,8 @@ import { createDaemonCommandHandlers } from "./daemon-manager";
const mocks = vi.hoisted(() => ({
paseoHome: "/tmp/paseo-desktop-daemon-manager-test-home",
desktopExecutablePath: "/opt/Paseo/Paseo",
managedDaemonExecutablePath: "/opt/Paseo/resources/Paseo",
settings: {
releaseChannel: "stable",
daemon: {
@@ -23,7 +25,9 @@ const mocks = vi.hoisted(() => ({
vi.mock("electron", () => ({
app: {
getPath: vi.fn(() => "/tmp/paseo-user-data"),
getPath: vi.fn((name: string) =>
name === "exe" ? mocks.desktopExecutablePath : "/tmp/paseo-user-data",
),
getVersion: vi.fn(() => "1.2.3"),
isPackaged: true,
},
@@ -91,6 +95,30 @@ function createMockChildProcess(): MockChildProcess {
return child;
}
function pidLockPath(): string {
return `${mocks.paseoHome}/paseo.pid`;
}
function writePidLock(input: {
pid: number;
executablePath?: string;
desktopManaged?: boolean;
}): void {
mkdirSync(mocks.paseoHome, { recursive: true });
writeFileSync(pidLockPath(), JSON.stringify(input));
}
function writeManagedPidLock(pid: number): void {
writePidLock({
pid,
executablePath: mocks.managedDaemonExecutablePath,
});
}
function removePidLock(): void {
rmSync(pidLockPath(), { force: true });
}
function scheduleFailedStartup(child: MockChildProcess): void {
setImmediate(() => {
child.emit("exit", 1, null);
@@ -150,7 +178,54 @@ describe("daemon-manager commands", () => {
expect(mocks.runExternalCliJsonCommand).toHaveBeenCalledWith(["daemon", "status", "--json"]);
});
it("derives desktop management from the pid lock executable path", async () => {
writeManagedPidLock(4242);
mocks.runExternalCliJsonCommand.mockResolvedValue({
localDaemon: "running",
connectedDaemon: "reachable",
serverId: "server-1",
pid: 4242,
listen: "127.0.0.1:6767",
daemonVersion: "1.2.3",
desktopManaged: false,
});
const handlers = createDaemonCommandHandlers();
await expect(handlers.desktop_daemon_status()).resolves.toEqual({
serverId: "server-1",
status: "running",
listen: "127.0.0.1:6767",
hostname: null,
pid: 4242,
home: mocks.paseoHome,
version: "1.2.3",
desktopManaged: true,
error: null,
});
});
it("falls back to the legacy desktopManaged lock field for old locks", async () => {
writePidLock({ pid: 4242, desktopManaged: true });
mocks.runExternalCliJsonCommand.mockResolvedValue({
localDaemon: "running",
connectedDaemon: "reachable",
serverId: "server-1",
pid: 4242,
listen: "127.0.0.1:6767",
daemonVersion: "1.2.3",
desktopManaged: false,
});
const handlers = createDaemonCommandHandlers();
await expect(handlers.desktop_daemon_status()).resolves.toMatchObject({
status: "running",
pid: 4242,
desktopManaged: true,
});
});
it("routes running desktop daemon stops through external CLI daemon stop", async () => {
writeManagedPidLock(4242);
mocks.runExternalCliJsonCommand
.mockResolvedValueOnce({
localDaemon: "running",
@@ -159,7 +234,10 @@ describe("daemon-manager commands", () => {
listen: "127.0.0.1:6767",
desktopManaged: true,
})
.mockResolvedValueOnce({ action: "stopped" })
.mockImplementationOnce(async () => {
removePidLock();
return { action: "stopped" };
})
.mockResolvedValueOnce({
localDaemon: "stopped",
serverId: "",
@@ -226,6 +304,7 @@ describe("daemon-manager commands", () => {
});
it("routes stale reachable desktop daemon stops through external CLI daemon stop", async () => {
writeManagedPidLock(7675);
mocks.runExternalCliJsonCommand
.mockResolvedValueOnce({
localDaemon: "stale_pid",
@@ -236,7 +315,10 @@ describe("daemon-manager commands", () => {
daemonVersion: "1.2.2",
desktopManaged: true,
})
.mockResolvedValueOnce({ action: "stopped" })
.mockImplementationOnce(async () => {
removePidLock();
return { action: "stopped" };
})
.mockResolvedValueOnce({
localDaemon: "stopped",
connectedDaemon: "unreachable",
@@ -269,6 +351,7 @@ describe("daemon-manager commands", () => {
});
it("records the renderer stop reason when stopping the desktop daemon", async () => {
writeManagedPidLock(4242);
mocks.runExternalCliJsonCommand
.mockResolvedValueOnce({
localDaemon: "running",
@@ -277,7 +360,10 @@ describe("daemon-manager commands", () => {
listen: "127.0.0.1:6767",
desktopManaged: true,
})
.mockResolvedValueOnce({ action: "stopped", reason: "lifecycle_shutdown_rpc" })
.mockImplementationOnce(async () => {
removePidLock();
return { action: "stopped", reason: "lifecycle_shutdown_rpc" };
})
.mockResolvedValueOnce({
localDaemon: "stopped",
serverId: "",
@@ -302,6 +388,7 @@ describe("daemon-manager commands", () => {
});
it("uses a stale reachable desktop daemon when the version matches", async () => {
writeManagedPidLock(7675);
mocks.runExternalCliJsonCommand.mockResolvedValue({
localDaemon: "stale_pid",
connectedDaemon: "reachable",
@@ -330,6 +417,7 @@ describe("daemon-manager commands", () => {
});
it("restarts a stale reachable desktop daemon when the version differs", async () => {
writeManagedPidLock(7675);
mocks.runExternalCliJsonCommand
.mockResolvedValueOnce({
localDaemon: "stale_pid",
@@ -350,21 +438,27 @@ describe("daemon-manager commands", () => {
daemonVersion: "1.2.2",
desktopManaged: true,
})
.mockResolvedValueOnce({ action: "stopped" })
.mockImplementationOnce(async () => {
removePidLock();
return { action: "stopped" };
})
.mockResolvedValueOnce({
localDaemon: "stopped",
connectedDaemon: "unreachable",
serverId: "",
})
.mockResolvedValueOnce({
localDaemon: "running",
connectedDaemon: "reachable",
serverId: "server-2",
pid: 8888,
listen: "127.0.0.1:6767",
hostname: "dev-host",
daemonVersion: "1.2.3",
desktopManaged: true,
.mockImplementationOnce(async () => {
writeManagedPidLock(8888);
return {
localDaemon: "running",
connectedDaemon: "reachable",
serverId: "server-2",
pid: 8888,
listen: "127.0.0.1:6767",
hostname: "dev-host",
daemonVersion: "1.2.3",
desktopManaged: true,
};
});
mocks.spawnProcess.mockReturnValue(createMockChildProcess());
const handlers = createDaemonCommandHandlers();
+34 -8
View File
@@ -39,6 +39,7 @@ import {
import type { DesktopSettings } from "../settings/desktop-settings.js";
import { getDesktopSettingsStore } from "../settings/desktop-settings-electron.js";
import { isRunningUnderARM64Translation } from "../system/arm64-translation.js";
import { deriveDesktopManagedFromExecutablePath } from "./desktop-managed.js";
const DAEMON_LOG_FILENAME = "daemon.log";
const STARTUP_POLL_INTERVAL_MS = 200;
@@ -123,18 +124,43 @@ function logFilePath(): string {
return path.join(getPaseoHome(), DAEMON_LOG_FILENAME);
}
export function isDesktopManagedDaemonRunningSync(): boolean {
function deriveDesktopManagedFromPidLock(lock: Record<string, unknown>): boolean {
const executablePath = toTrimmedString(lock.executablePath);
if (executablePath) {
return deriveDesktopManagedFromExecutablePath({
daemonExecutablePath: executablePath,
desktopExecutablePath: app.getPath("exe"),
platform: process.platform,
});
}
// COMPAT(desktopManagedPidLock): added in v0.1.105, remove after 2027-01-06 once locks without executablePath have aged out.
return lock.desktopManaged === true;
}
function readPidLockRecord(): Record<string, unknown> | null {
try {
const raw = readFileSync(path.join(getPaseoHome(), "paseo.pid"), "utf-8");
const lock = JSON.parse(raw) as { pid?: unknown; desktopManaged?: unknown };
if (lock.desktopManaged !== true) return false;
if (typeof lock.pid !== "number" || !Number.isInteger(lock.pid)) return false;
return isProcessRunning(lock.pid);
const lock = JSON.parse(raw) as unknown;
return isRecord(lock) ? lock : null;
} catch {
return false;
return null;
}
}
function readDesktopManagedFromPidLock(): boolean {
const lock = readPidLockRecord();
return lock !== null && deriveDesktopManagedFromPidLock(lock);
}
export function isDesktopManagedDaemonRunningSync(): boolean {
const lock = readPidLockRecord();
if (lock === null) return false;
if (!deriveDesktopManagedFromPidLock(lock)) return false;
if (typeof lock.pid !== "number" || !Number.isInteger(lock.pid)) return false;
return isProcessRunning(lock.pid);
}
function summarizeDesktopDaemonStatus(status: DesktopDaemonStatus): Record<string, unknown> {
return {
status: status.status,
@@ -276,7 +302,7 @@ export async function resolveDesktopDaemonStatus(): Promise<DesktopDaemonStatus>
typeof payload.connectedDaemon === "string" ? payload.connectedDaemon : "not_probed";
const hasRunningLocalProcess = localDaemon === "running";
const hasLocalProcess = hasRunningLocalProcess || localDaemon === "unresponsive";
const desktopManaged = payload.desktopManaged === true;
const desktopManaged = readDesktopManagedFromPidLock();
const apiReachable = connectedDaemon === "reachable";
let status: DesktopDaemonState = "stopped";
if (apiReachable || hasRunningLocalProcess) {
@@ -418,7 +444,7 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
detached: true,
envMode: "internal",
env: invocation.env,
envOverlay: { PASEO_DESKTOP_MANAGED: "1", PASEO_WEB_UI_ENABLED: "false" },
envOverlay: { PASEO_WEB_UI_ENABLED: "false" },
stdio: ["ignore", "ignore", "ignore"],
});
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import { deriveDesktopManagedFromExecutablePath } from "./desktop-managed";
describe("desktop managed daemon executable derivation", () => {
it("treats the macOS Helper inside the app bundle as desktop managed", () => {
expect(
deriveDesktopManagedFromExecutablePath({
desktopExecutablePath: "/Applications/Paseo.app/Contents/MacOS/Paseo",
daemonExecutablePath:
"/Applications/Paseo.app/Contents/Frameworks/Paseo Helper.app/Contents/MacOS/Paseo Helper",
platform: "darwin",
}),
).toBe(true);
});
it("rejects a macOS Helper from a different app bundle", () => {
expect(
deriveDesktopManagedFromExecutablePath({
desktopExecutablePath: "/Applications/Paseo.app/Contents/MacOS/Paseo",
daemonExecutablePath:
"/Applications/Other.app/Contents/Frameworks/Paseo Helper.app/Contents/MacOS/Paseo Helper",
platform: "darwin",
}),
).toBe(false);
});
it("treats executables inside the Windows install directory as desktop managed", () => {
expect(
deriveDesktopManagedFromExecutablePath({
desktopExecutablePath: "C:\\Users\\me\\AppData\\Local\\Programs\\Paseo\\Paseo.exe",
daemonExecutablePath:
"c:\\users\\me\\appdata\\local\\programs\\paseo\\resources\\Paseo.exe",
platform: "win32",
}),
).toBe(true);
});
it("rejects system Node on Windows", () => {
expect(
deriveDesktopManagedFromExecutablePath({
desktopExecutablePath: "C:\\Users\\me\\AppData\\Local\\Programs\\Paseo\\Paseo.exe",
daemonExecutablePath: "C:\\Program Files\\nodejs\\node.exe",
platform: "win32",
}),
).toBe(false);
});
it("treats Linux executables from the same install directory as desktop managed", () => {
expect(
deriveDesktopManagedFromExecutablePath({
desktopExecutablePath: "/opt/Paseo/Paseo",
daemonExecutablePath: "/opt/Paseo/resources/Paseo",
platform: "linux",
}),
).toBe(true);
});
it("rejects npm-installed CLI daemon executables", () => {
expect(
deriveDesktopManagedFromExecutablePath({
desktopExecutablePath: "/opt/Paseo/Paseo",
daemonExecutablePath: "/usr/local/bin/node",
platform: "linux",
}),
).toBe(false);
});
});
@@ -0,0 +1,69 @@
import path from "node:path";
interface DesktopManagedInput {
daemonExecutablePath: string | null | undefined;
desktopExecutablePath: string;
platform: NodeJS.Platform;
}
function pathModuleForPlatform(platform: NodeJS.Platform): typeof path.win32 | typeof path.posix {
return platform === "win32" ? path.win32 : path.posix;
}
function normalizeForComparison(filePath: string, platform: NodeJS.Platform): string {
const pathModule = pathModuleForPlatform(platform);
const normalized = pathModule.normalize(filePath.trim());
return platform === "win32" ? normalized.toLowerCase() : normalized;
}
function resolveMacAppRoot(filePath: string): string | null {
const marker = ".app/";
const markerIndex = filePath.indexOf(marker);
if (markerIndex === -1) {
return filePath.endsWith(".app") ? filePath : null;
}
return filePath.slice(0, markerIndex + ".app".length);
}
function resolveDesktopInstallRoot(input: {
desktopExecutablePath: string;
platform: NodeJS.Platform;
}): string {
const normalized = normalizeForComparison(input.desktopExecutablePath, input.platform);
if (input.platform === "darwin") {
return resolveMacAppRoot(normalized) ?? path.posix.dirname(normalized);
}
return pathModuleForPlatform(input.platform).dirname(normalized);
}
function isSamePathOrInside(input: {
candidatePath: string;
parentPath: string;
platform: NodeJS.Platform;
}): boolean {
const pathModule = pathModuleForPlatform(input.platform);
const candidatePath = normalizeForComparison(input.candidatePath, input.platform);
const parentPath = normalizeForComparison(input.parentPath, input.platform);
const relative = pathModule.relative(parentPath, candidatePath);
return (
relative === "" ||
(relative.length > 0 && !relative.startsWith("..") && !pathModule.isAbsolute(relative))
);
}
export function deriveDesktopManagedFromExecutablePath(input: DesktopManagedInput): boolean {
if (!input.daemonExecutablePath?.trim()) {
return false;
}
const desktopInstallRoot = resolveDesktopInstallRoot({
desktopExecutablePath: input.desktopExecutablePath,
platform: input.platform,
});
return isSamePathOrInside({
candidatePath: input.daemonExecutablePath,
parentPath: desktopInstallRoot,
platform: input.platform,
});
}
@@ -0,0 +1,198 @@
import type { SnapshotPage } from "./snapshot-engine.js";
export interface ActionablePoint {
x: number;
y: number;
}
export interface ActionableTarget {
point: ActionablePoint;
rect: {
x: number;
y: number;
width: number;
height: number;
};
}
export type ActionabilityResult =
| { ok: true; target: ActionableTarget }
| { ok: false; reason: "stale_ref" | "timeout"; detail?: string };
const DEFAULT_ACTIONABILITY_TIMEOUT_MS = 5_000;
export async function waitForActionableTarget(input: {
page: SnapshotPage;
elementExpression: string;
editable?: boolean;
timeoutMs?: number;
}): Promise<ActionabilityResult> {
const result = await input.page.executeJavaScript(
buildActionabilityScript({
elementExpression: input.elementExpression,
editable: input.editable === true,
timeoutMs: input.timeoutMs ?? DEFAULT_ACTIONABILITY_TIMEOUT_MS,
}),
);
return readActionabilityResult(result);
}
function readActionabilityResult(value: unknown): ActionabilityResult {
if (!value || typeof value !== "object") {
return { ok: false, reason: "timeout" };
}
const record = value as Record<string, unknown>;
if (record.ok === true && isActionableTarget(record.target)) {
return { ok: true, target: record.target };
}
if (record.ok === false) {
const reason = record.reason;
if (reason === "stale_ref" || reason === "timeout") {
return {
ok: false,
reason,
...(typeof record.detail === "string" ? { detail: record.detail } : {}),
};
}
}
return { ok: false, reason: "timeout" };
}
function isActionableTarget(value: unknown): value is ActionableTarget {
if (!value || typeof value !== "object") {
return false;
}
const record = value as Record<string, unknown>;
return isPoint(record.point) && isRect(record.rect);
}
function isPoint(value: unknown): value is ActionablePoint {
if (!value || typeof value !== "object") {
return false;
}
const record = value as Record<string, unknown>;
return isFiniteNumber(record.x) && isFiniteNumber(record.y);
}
function isRect(value: unknown): value is ActionableTarget["rect"] {
if (!value || typeof value !== "object") {
return false;
}
const record = value as Record<string, unknown>;
return (
isFiniteNumber(record.x) &&
isFiniteNumber(record.y) &&
isFiniteNumber(record.width) &&
isFiniteNumber(record.height)
);
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
function buildActionabilityScript(input: {
elementExpression: string;
editable: boolean;
timeoutMs: number;
}): string {
return String.raw`(async () => {
const deadline = performance.now() + ${JSON.stringify(input.timeoutMs)};
const requiresEditable = ${JSON.stringify(input.editable)};
const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve));
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const nearlyEqual = (a, b) => Math.abs(a - b) < 0.25;
const sameRect = (a, b) =>
nearlyEqual(a.x, b.x) &&
nearlyEqual(a.y, b.y) &&
nearlyEqual(a.width, b.width) &&
nearlyEqual(a.height, b.height);
const rectPayload = (rect) => ({
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
});
const centerPoint = (rect) => ({
x: Math.min(Math.max(rect.left + rect.width / 2, 0), Math.max(window.innerWidth - 1, 0)),
y: Math.min(Math.max(rect.top + rect.height / 2, 0), Math.max(window.innerHeight - 1, 0)),
});
const isDisabled = (element) => {
if (element.closest?.('[aria-disabled="true"]')) return true;
if ('disabled' in element && element.disabled) return true;
const fieldset = element.closest?.('fieldset[disabled]');
return Boolean(fieldset);
};
const isEditable = (element) => {
if (element.isContentEditable) return true;
const tag = element.tagName?.toLowerCase();
if (tag === 'textarea' || tag === 'select') return !element.readOnly && !isDisabled(element);
if (tag !== 'input') return false;
const type = (element.getAttribute('type') || 'text').toLowerCase();
if (['button', 'checkbox', 'color', 'file', 'hidden', 'image', 'radio', 'range', 'reset', 'submit'].includes(type)) return false;
return !element.readOnly && !isDisabled(element);
};
const isVisible = (element, rect) => {
const style = getComputedStyle(element);
return (
rect.width > 0 &&
rect.height > 0 &&
style.visibility !== 'hidden' &&
style.display !== 'none' &&
Number(style.opacity || '1') !== 0
);
};
const hitTargetReceivesEvents = (element, point) => {
const hit = document.elementFromPoint(point.x, point.y);
return Boolean(hit && (hit === element || element.contains(hit)));
};
const resolveElement = () => (${input.elementExpression});
let detail = 'not actionable';
while (performance.now() <= deadline) {
const element = resolveElement();
if (!element || !element.isConnected) {
return { ok: false, reason: 'stale_ref', detail: 'ref no longer resolves' };
}
const rect = element.getBoundingClientRect();
if (!isVisible(element, rect)) {
detail = 'not visible';
await sleep(25);
continue;
}
if (isDisabled(element)) {
detail = 'disabled';
await sleep(25);
continue;
}
if (requiresEditable && !isEditable(element)) {
detail = 'not editable';
await sleep(25);
continue;
}
element.scrollIntoView?.({ block: 'center', inline: 'center' });
await nextFrame();
const firstRect = element.getBoundingClientRect();
await nextFrame();
const secondRect = element.getBoundingClientRect();
if (!sameRect(firstRect, secondRect)) {
detail = 'moving';
continue;
}
const point = centerPoint(secondRect);
if (!hitTargetReceivesEvents(element, point)) {
detail = 'covered';
await sleep(25);
continue;
}
return { ok: true, target: { point, rect: rectPayload(secondRect) } };
}
return { ok: false, reason: 'timeout', detail };
})()`;
}
@@ -0,0 +1,311 @@
export const ARIA_SNAPSHOT_SCRIPT_MARKER = "__PASEO_ARIA_SNAPSHOT__";
// Adapted from Playwright's injected ARIA snapshot model.
// Copyright (c) Microsoft Corporation. Licensed under the Apache License, Version 2.0.
export const ARIA_SNAPSHOT_SCRIPT = String.raw`(() => {
const MARKER = ${JSON.stringify(ARIA_SNAPSHOT_SCRIPT_MARKER)};
const MAX_NODES = 1500;
const MAX_REFS = 500;
const MAX_TEXT_LENGTH = 80000;
const ACTIONABLE_ROLES = new Set([
'button',
'checkbox',
'combobox',
'link',
'menuitem',
'option',
'radio',
'searchbox',
'slider',
'spinbutton',
'switch',
'tab',
'textbox',
'treeitem'
]);
function normalizeText(value) {
return String(value || '').replace(/[\u200b\u00ad]/g, '').replace(/[\r\n\s\t]+/g, ' ').trim();
}
function visibilityFor(element) {
if (!(element instanceof Element)) return false;
const style = window.getComputedStyle(element);
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) return false;
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0 ? 'box' : 'boxless';
}
function explicitRole(element) {
const role = element.getAttribute('role');
if (!role || role === 'presentation' || role === 'none') return null;
return role.split(/\s+/)[0].toLowerCase();
}
function implicitRole(element) {
const tag = element.tagName.toLowerCase();
if (/^h[1-6]$/.test(tag)) return 'heading';
if (tag === 'a' && element.hasAttribute('href')) return 'link';
if (tag === 'button') return 'button';
if (tag === 'select') return 'combobox';
if (tag === 'textarea') return 'textbox';
if (element instanceof HTMLElement && element.isContentEditable) return 'textbox';
if (tag === 'summary') return 'button';
if (tag === 'main') return 'main';
if (tag === 'nav') return 'navigation';
if (tag === 'header') return 'banner';
if (tag === 'footer') return 'contentinfo';
if (tag === 'section') return element.getAttribute('aria-label') ? 'region' : null;
if (tag === 'ul' || tag === 'ol') return 'list';
if (tag === 'li') return 'listitem';
if (tag === 'table') return 'table';
if (tag === 'tr') return 'row';
if (tag === 'th') return 'columnheader';
if (tag === 'td') return 'cell';
if (tag === 'iframe') return 'iframe';
if (tag === 'input') {
const type = (element.getAttribute('type') || 'text').toLowerCase();
if (type === 'checkbox') return 'checkbox';
if (type === 'radio') return 'radio';
if (type === 'button' || type === 'submit' || type === 'reset') return 'button';
if (type === 'range') return 'slider';
if (type === 'number') return 'spinbutton';
if (type === 'search') return 'searchbox';
if (type === 'hidden') return null;
return 'textbox';
}
return null;
}
function roleFor(element) {
return explicitRole(element) || implicitRole(element);
}
function labelText(element) {
if (!(element instanceof HTMLElement)) return '';
if (element.id) {
const escapedId = window.CSS && typeof window.CSS.escape === 'function'
? window.CSS.escape(element.id)
: String(element.id).replace(/"/g, '\\"');
const label = document.querySelector('label[for="' + escapedId + '"]');
if (label) return normalizeText(label.textContent);
}
const closestLabel = element.closest('label');
return closestLabel ? normalizeText(closestLabel.textContent) : '';
}
function nameFor(element, role) {
const tag = element.tagName.toLowerCase();
const labelledBy = element.getAttribute('aria-labelledby');
if (labelledBy) {
const text = labelledBy.split(/\s+/).map((id) => document.getElementById(id)?.textContent || '').join(' ');
const normalized = normalizeText(text);
if (normalized) return normalized;
}
const pieces = [
element.getAttribute('aria-label'),
labelText(element),
element.getAttribute('alt'),
element.getAttribute('title'),
tag === 'input' || tag === 'textarea' ? element.getAttribute('placeholder') : null,
tag === 'input' || tag === 'textarea' ? element.value : null,
role === 'button' || role === 'link' || role === 'heading' || ACTIONABLE_ROLES.has(role)
? element.textContent
: null
];
return normalizeText(pieces.find((piece) => normalizeText(piece).length > 0) || '');
}
function fingerprintNameFor(element, role, name) {
const tag = element.tagName.toLowerCase();
if (tag !== 'input' && tag !== 'textarea') return name;
const mutableValue = normalizeText(element.value);
if (!mutableValue || name !== mutableValue) return name;
return '';
}
function inheritedDisabled(element) {
if (!(element instanceof Element)) return false;
if (element.hasAttribute('disabled') || element.getAttribute('aria-disabled') === 'true') return true;
if (element.closest('fieldset[disabled]')) return true;
return element.closest('[aria-disabled="true"]') !== null;
}
function isActionable(element, role) {
if (!role || !ACTIONABLE_ROLES.has(role)) return false;
if (visibilityFor(element) !== 'box') return false;
if (inheritedDisabled(element)) return false;
return true;
}
function fingerprintFor(element, role, name) {
return {
role,
name: fingerprintNameFor(element, role, name),
tagName: element.tagName.toLowerCase(),
type: element.getAttribute('type') || '',
ariaLabel: element.getAttribute('aria-label') || ''
};
}
function fingerprintMatches(element, fingerprint) {
const role = roleFor(element) || 'generic';
const name = nameFor(element, role);
const current = fingerprintFor(element, role, name);
return current.role === fingerprint.role &&
current.name === fingerprint.name &&
current.tagName === fingerprint.tagName &&
current.type === fingerprint.type &&
current.ariaLabel === fingerprint.ariaLabel;
}
function ensureRuntime() {
const runtime = {
refs: new Map(),
resolve(ref, fingerprint) {
const element = this.refs.get(ref);
if (!element || !element.isConnected || !fingerprintMatches(element, fingerprint)) {
return { ok: false, reason: 'stale_ref' };
}
return { ok: true, element };
}
};
Object.defineProperty(window, '__PASEO_BROWSER_AUTOMATION__', {
configurable: true,
enumerable: false,
value: runtime
});
return runtime;
}
function stateAttributes(element, role) {
const attrs = [];
if (role === 'heading') {
const tag = element.tagName.toLowerCase();
const level = /^h[1-6]$/.test(tag) ? Number(tag.slice(1)) : Number(element.getAttribute('aria-level') || 0);
if (level) attrs.push('level=' + level);
}
if (element.matches?.('input[type="checkbox"], input[type="radio"]')) {
attrs.push('checked=' + (element.checked ? 'true' : 'false'));
}
if (element.getAttribute('aria-checked')) attrs.push('checked=' + element.getAttribute('aria-checked'));
if (element.getAttribute('aria-expanded')) attrs.push('expanded=' + element.getAttribute('aria-expanded'));
if (element.getAttribute('aria-pressed')) attrs.push('pressed=' + element.getAttribute('aria-pressed'));
if (element.getAttribute('aria-selected')) attrs.push('selected=' + element.getAttribute('aria-selected'));
return attrs;
}
function textNode(text) {
return { kind: 'text', text };
}
function elementNode(element, role, name) {
return {
kind: 'role',
role: role || 'generic',
name,
tagName: element.tagName.toLowerCase(),
attributes: stateAttributes(element, role),
children: []
};
}
let nodeCount = 0;
let refCount = 0;
let iframeCount = 0;
let maxDepth = 0;
let truncated = false;
let textBudget = MAX_TEXT_LENGTH;
const runtime = ensureRuntime();
function countNode(depth) {
if (nodeCount >= MAX_NODES) {
truncated = true;
return false;
}
nodeCount += 1;
maxDepth = Math.max(maxDepth, depth);
return true;
}
function cappedText(text) {
if (text.length <= textBudget) {
textBudget -= text.length;
return text;
}
truncated = true;
const capped = text.slice(0, Math.max(0, textBudget));
textBudget = 0;
return capped;
}
function visitNode(domNode, depth) {
if (!countNode(depth)) return null;
if (domNode.nodeType === Node.TEXT_NODE) {
const text = cappedText(normalizeText(domNode.textContent));
if (!text) return null;
return textNode(text);
}
if (!(domNode instanceof Element)) return null;
const visibility = visibilityFor(domNode);
if (!visibility) return null;
if (domNode.getAttribute('aria-hidden') === 'true') return null;
const role = roleFor(domNode);
const name = role ? nameFor(domNode, role) : '';
const children = [];
for (const child of Array.from(domNode.childNodes)) {
const childSnapshot = visitNode(child, depth + 1);
if (childSnapshot) children.push(childSnapshot);
if (truncated) break;
}
if (domNode.tagName.toLowerCase() === 'iframe') {
iframeCount += 1;
}
if (visibility === 'boxless') {
return children.length > 0 ? { kind: 'group', children } : null;
}
if (!role && children.length === 0) return null;
const snapshotNode = role ? elementNode(domNode, role, name) : { kind: 'group', children: [] };
snapshotNode.children = children;
if (role && isActionable(domNode, role) && refCount < MAX_REFS) {
const ref = '@e' + (refCount + 1);
const fingerprint = fingerprintFor(domNode, role, name);
refCount += 1;
runtime.refs.set(ref, domNode);
snapshotNode.ref = ref;
snapshotNode.fingerprint = fingerprint;
} else if (role && isActionable(domNode, role)) {
truncated = true;
}
return snapshotNode;
}
const root = {
kind: 'role',
role: 'document',
name: normalizeText(document.title),
tagName: 'document',
attributes: [],
children: []
};
for (const child of Array.from(document.body ? document.body.childNodes : document.documentElement.childNodes)) {
const childSnapshot = visitNode(child, 1);
if (childSnapshot) root.children.push(childSnapshot);
if (truncated) break;
}
return JSON.stringify({
marker: MARKER,
root,
refs: Array.from(runtime.refs.entries()).map(([ref, element]) => {
const role = roleFor(element) || 'generic';
const name = nameFor(element, role);
return { ref, fingerprint: fingerprintFor(element, role, name) };
}),
truncated,
stats: { nodeCount, refCount, textLength: 0, iframeCount, maxDepth }
});
})()`;
@@ -0,0 +1,28 @@
export type CdpCommandSender = (
command: string,
params?: Record<string, unknown>,
) => Promise<unknown>;
export class CdpSessionQueue {
private queue: Promise<void> = Promise.resolve();
public async run<T>(task: () => Promise<T>): Promise<T> {
const previous = this.queue;
let releaseCurrent = () => {};
const current = new Promise<void>((resolve) => {
releaseCurrent = resolve;
});
const tail = previous.catch(() => {}).then(() => current);
this.queue = tail;
await previous.catch(() => {});
try {
return await task();
} finally {
releaseCurrent();
if (this.queue === tail) {
this.queue = Promise.resolve();
}
}
}
}
@@ -0,0 +1,89 @@
import type { BrowserAutomationDialogEvent } from "@getpaseo/protocol/browser-automation/rpc-schemas";
export const MAX_DIALOGS_PER_COMMAND = 20;
export interface JavaScriptDialogOpening {
type?: unknown;
message?: unknown;
defaultPrompt?: unknown;
}
interface DialogPolicy {
readonly action: BrowserAutomationDialogEvent["action"];
readonly accept: boolean;
}
const DIALOG_POLICIES: Record<BrowserAutomationDialogEvent["type"], DialogPolicy> = {
alert: { action: "accepted", accept: true },
confirm: { action: "dismissed", accept: false },
prompt: { action: "dismissed", accept: false },
beforeunload: { action: "dismissed", accept: false },
};
export function handledDialogEvent(opening: JavaScriptDialogOpening): BrowserAutomationDialogEvent {
const type = normalizeDialogType(opening.type);
const policy = DIALOG_POLICIES[type];
return {
type,
message: typeof opening.message === "string" ? opening.message : String(opening.message ?? ""),
...(typeof opening.defaultPrompt === "string" ? { defaultValue: opening.defaultPrompt } : {}),
action: policy.action,
timestamp: Date.now(),
};
}
export function dialogAcceptValue(type: BrowserAutomationDialogEvent["type"]): boolean {
return DIALOG_POLICIES[type].accept;
}
export function promptShimInstallScript(): string {
return String.raw`(() => {
const stateKey = "__PASEO_BROWSER_AUTOMATION_DIALOG_STATE__";
const state = window[stateKey] || { prompts: [], installed: false };
window[stateKey] = state;
if (state.installed) return true;
const originalPrompt = window.prompt;
Object.defineProperty(state, "originalPrompt", { configurable: true, value: originalPrompt });
const promptShim = (message = "", defaultValue = "") => {
state.prompts.push({
type: "prompt",
message: String(message ?? ""),
defaultValue: String(defaultValue ?? ""),
action: "dismissed",
timestamp: Date.now(),
});
return null;
};
Object.defineProperty(state, "promptShim", { configurable: true, value: promptShim });
window.prompt = promptShim;
state.installed = true;
return true;
})()`;
}
export function promptShimDrainScript(): string {
return String.raw`(() => {
const state = window.__PASEO_BROWSER_AUTOMATION_DIALOG_STATE__;
if (!state || !Array.isArray(state.prompts)) return [];
return state.prompts.splice(0);
})()`;
}
export function promptShimRestoreScript(): string {
return String.raw`(() => {
const stateKey = "__PASEO_BROWSER_AUTOMATION_DIALOG_STATE__";
const state = window[stateKey];
if (!state || !state.installed) return true;
if (window.prompt === state.promptShim && typeof state.originalPrompt === "function") {
window.prompt = state.originalPrompt;
}
delete window[stateKey];
return true;
})()`;
}
function normalizeDialogType(value: unknown): BrowserAutomationDialogEvent["type"] {
return value === "alert" || value === "confirm" || value === "prompt" || value === "beforeunload"
? value
: "alert";
}
@@ -1,5 +1,5 @@
import type { Rectangle } from "electron";
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";
import type { TabImage } from "./service.js";
import { adaptWebContents } from "./ipc.js";
@@ -16,6 +16,15 @@ class FakeImage implements TabImage {
class FakeDebugger {
public attachedProtocolVersions: string[] = [];
public commands: Array<{ command: string; params: Record<string, unknown> }> = [];
public blockCommands = false;
public readonly blockedCommandNames = new Set<string>();
public readonly failedCommandNames = new Set<string>();
public readonly promptDialogs: unknown[] = [];
public failPromptDrain = false;
private messageListener:
| ((event: unknown, method: string, params?: Record<string, unknown>) => void)
| null = null;
private readonly blockedCommands: Array<() => void> = [];
public isAttached(): boolean {
return this.attachedProtocolVersions.length > 0;
@@ -27,8 +36,48 @@ class FakeDebugger {
public async sendCommand(command: string, params?: Record<string, unknown>): Promise<unknown> {
this.commands.push({ command, params: params ?? {} });
if (this.failedCommandNames.has(command)) {
throw new Error(`${command} failed`);
}
if (this.blockCommands || this.blockedCommandNames.has(command)) {
await new Promise<void>((resolve) => {
this.blockedCommands.push(resolve);
});
}
if (command === "Runtime.evaluate" && typeof params?.expression === "string") {
if (params.expression.includes("state.prompts.splice(0)")) {
if (this.failPromptDrain) {
throw new Error("execution context destroyed");
}
return { result: { value: this.promptDialogs.splice(0) } };
}
return { result: { value: true } };
}
return { ok: true };
}
public on(
event: "message",
listener: (event: unknown, method: string, params?: Record<string, unknown>) => void,
): void {
expect(event).toBe("message");
this.messageListener = listener;
}
public emitMessage(method: string, params?: Record<string, unknown>): void {
if (!this.messageListener) {
throw new Error("Debugger message listener was not registered");
}
this.messageListener({}, method, params);
}
public finishNextCommand(): void {
const resolve = this.blockedCommands.shift();
if (!resolve) {
throw new Error("No command is blocked");
}
resolve();
}
}
type ConsoleMessageListener = (
@@ -181,4 +230,323 @@ describe("browser automation IPC adapter", () => {
{ command: "Page.captureScreenshot", params: { format: "png" } },
]);
});
test("serializes CDP commands per guest contents", async () => {
const contents = new FakeWebContents(23);
contents.debugger.blockCommands = true;
const tab = adaptWebContents(contents);
const first = tab.sendDebugCommand?.("Input.dispatchMouseEvent", { type: "mouseMoved" });
const second = tab.sendDebugCommand?.("Page.captureScreenshot", { format: "png" });
await flushMicrotasks();
expect(contents.debugger.commands).toEqual([
{ command: "Input.dispatchMouseEvent", params: { type: "mouseMoved" } },
]);
contents.debugger.finishNextCommand();
await flushMicrotasks();
expect(contents.debugger.commands).toEqual([
{ command: "Input.dispatchMouseEvent", params: { type: "mouseMoved" } },
{ command: "Page.captureScreenshot", params: { format: "png" } },
]);
contents.debugger.finishNextCommand();
await expect(first).resolves.toEqual({ ok: true });
await expect(second).resolves.toEqual({ ok: true });
});
test("handles JavaScript dialogs through the per-tab CDP queue", async () => {
const contents = new FakeWebContents(24);
const tab = adaptWebContents(contents);
const captured = tab.captureDialogs?.(async () => {
contents.debugger.blockCommands = true;
const input = tab.sendDebugCommand?.("Input.dispatchMouseEvent", { type: "mouseReleased" });
await flushMicrotasks();
contents.debugger.emitMessage("Page.javascriptDialogOpening", {
type: "confirm",
message: "Delete item?",
});
await flushMicrotasks();
expect(contents.debugger.commands).toEqual([
{ command: "Page.enable", params: {} },
{
command: "Runtime.evaluate",
params: { expression: expect.any(String), returnByValue: true },
},
{ command: "Input.dispatchMouseEvent", params: { type: "mouseReleased" } },
{ command: "Page.handleJavaScriptDialog", params: { accept: false } },
]);
contents.debugger.blockCommands = false;
contents.debugger.finishNextCommand();
await input;
await flushMicrotasks();
return "done";
});
await expect(captured).resolves.toEqual({
result: "done",
dialogs: [
{
type: "confirm",
message: "Delete item?",
action: "dismissed",
timestamp: expect.any(Number),
},
],
});
expect(contents.debugger.commands).toEqual([
{ command: "Page.enable", params: {} },
{
command: "Runtime.evaluate",
params: { expression: expect.any(String), returnByValue: true },
},
{ command: "Input.dispatchMouseEvent", params: { type: "mouseReleased" } },
{ command: "Page.handleJavaScriptDialog", params: { accept: false } },
{
command: "Runtime.evaluate",
params: { expression: expect.any(String), returnByValue: true },
},
{
command: "Runtime.evaluate",
params: { expression: expect.any(String), returnByValue: true },
},
]);
});
test("handles JavaScript dialogs while the triggering CDP input command is still in flight", async () => {
const contents = new FakeWebContents(25);
const tab = adaptWebContents(contents);
const captured = tab.captureDialogs?.(async () => {
contents.debugger.blockedCommandNames.add("Input.dispatchMouseEvent");
const input = tab.sendDebugCommand?.("Input.dispatchMouseEvent", { type: "mousePressed" });
await flushMicrotasks();
contents.debugger.emitMessage("Page.javascriptDialogOpening", {
type: "alert",
message: "Saved",
});
await flushMicrotasks();
expect(contents.debugger.commands).toEqual([
{ command: "Page.enable", params: {} },
{
command: "Runtime.evaluate",
params: { expression: expect.any(String), returnByValue: true },
},
{ command: "Input.dispatchMouseEvent", params: { type: "mousePressed" } },
{ command: "Page.handleJavaScriptDialog", params: { accept: true } },
]);
contents.debugger.blockedCommandNames.clear();
contents.debugger.finishNextCommand();
await input;
return "done";
});
await expect(captured).resolves.toEqual({
result: "done",
dialogs: [
{
type: "alert",
message: "Saved",
action: "accepted",
timestamp: expect.any(Number),
},
],
});
expect(contents.debugger.commands.at(-1)).toEqual({
command: "Runtime.evaluate",
params: {
expression: expect.stringContaining("delete window[stateKey]"),
returnByValue: true,
},
});
});
test("keeps the prompt shim installed until overlapping captures finish", async () => {
const contents = new FakeWebContents(26);
const tab = adaptWebContents(contents);
const firstStarted = deferred<void>();
const secondStarted = deferred<void>();
const finishFirst = deferred<void>();
const finishSecond = deferred<void>();
const first = tab.captureDialogs?.(async () => {
firstStarted.resolve();
await finishFirst.promise;
return "first";
});
await firstStarted.promise;
const second = tab.captureDialogs?.(async () => {
secondStarted.resolve();
await finishSecond.promise;
return "second";
});
await secondStarted.promise;
contents.debugger.promptDialogs.push(
{
type: "prompt",
message: "First?",
defaultValue: "one",
action: "dismissed",
timestamp: 1,
},
{
type: "prompt",
message: "Second?",
defaultValue: "two",
action: "dismissed",
timestamp: 2,
},
);
finishFirst.resolve();
await expect(first).resolves.toEqual({
result: "first",
dialogs: [
{
type: "prompt",
message: "First?",
defaultValue: "one",
action: "dismissed",
timestamp: 1,
},
{
type: "prompt",
message: "Second?",
defaultValue: "two",
action: "dismissed",
timestamp: 2,
},
],
});
expect(
contents.debugger.commands.some(
(entry) =>
entry.command === "Runtime.evaluate" &&
typeof entry.params.expression === "string" &&
entry.params.expression.includes("delete window[stateKey]"),
),
).toBe(false);
finishSecond.resolve();
await expect(second).resolves.toEqual({
result: "second",
dialogs: [
{
type: "prompt",
message: "First?",
defaultValue: "one",
action: "dismissed",
timestamp: 1,
},
{
type: "prompt",
message: "Second?",
defaultValue: "two",
action: "dismissed",
timestamp: 2,
},
],
});
expect(contents.debugger.commands.at(-1)).toEqual({
command: "Runtime.evaluate",
params: {
expression: expect.stringContaining("delete window[stateKey]"),
returnByValue: true,
},
});
});
test("leaves JavaScript dialogs alone when no capture is active", async () => {
const contents = new FakeWebContents(28);
const tab = adaptWebContents(contents);
await expect(tab.captureDialogs?.(async () => "done")).resolves.toEqual({
result: "done",
dialogs: [],
});
contents.debugger.emitMessage("Page.javascriptDialogOpening", {
type: "confirm",
message: "Unsaved changes?",
});
await flushMicrotasks();
expect(contents.debugger.commands).not.toContainEqual({
command: "Page.handleJavaScriptDialog",
params: { accept: false },
});
});
test("treats prompt shim drain failures after navigation as no dialogs", async () => {
const contents = new FakeWebContents(29);
contents.debugger.failPromptDrain = true;
const tab = adaptWebContents(contents);
await expect(tab.captureDialogs?.(async () => "navigated")).resolves.toEqual({
result: "navigated",
dialogs: [],
});
expect(contents.debugger.commands).toEqual([
{ command: "Page.enable", params: {} },
{
command: "Runtime.evaluate",
params: { expression: expect.any(String), returnByValue: true },
},
{
command: "Runtime.evaluate",
params: { expression: expect.any(String), returnByValue: true },
},
{
command: "Runtime.evaluate",
params: {
expression: expect.stringContaining("delete window[stateKey]"),
returnByValue: true,
},
},
]);
});
test("runs the command without dialog capture when CDP setup fails", async () => {
const contents = new FakeWebContents(30);
contents.debugger.failedCommandNames.add("Page.enable");
const tab = adaptWebContents(contents);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
await expect(tab.captureDialogs?.(async () => "done")).resolves.toEqual({
result: "done",
dialogs: [],
});
expect(warn).toHaveBeenCalledWith(
"[browser-automation] Dialog capture unavailable; running command without it",
{ contentsId: 30, error: expect.any(Error) },
);
warn.mockRestore();
});
});
async function flushMicrotasks(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}
function deferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
@@ -1,8 +1,20 @@
import type { Rectangle } from "electron";
import { ipcMain } from "electron";
import { BrowserAutomationExecuteRequestSchema } from "@getpaseo/protocol/browser-automation/rpc-schemas";
import type { BrowserAutomationConsoleLogEntry } from "@getpaseo/protocol/browser-automation/rpc-schemas";
import type {
BrowserAutomationConsoleLogEntry,
BrowserAutomationDialogEvent,
} from "@getpaseo/protocol/browser-automation/rpc-schemas";
import type { TabContents, BrowserRegistry, TabImage } from "./service.js";
import { CdpSessionQueue } from "./cdp-session-queue.js";
import {
dialogAcceptValue,
handledDialogEvent,
MAX_DIALOGS_PER_COMMAND,
promptShimDrainScript,
promptShimInstallScript,
promptShimRestoreScript,
} from "./dialog-handling.js";
import { executeAutomationCommand } from "./service.js";
import {
listRegisteredPaseoBrowserIds,
@@ -14,6 +26,8 @@ import {
const MAX_CONSOLE_MESSAGES_PER_TAB = 200;
const consoleMessagesByContentsId = new Map<number, BrowserAutomationConsoleLogEntry[]>();
const cdpQueuesByContentsId = new Map<number, CdpSessionQueue>();
const dialogMonitorsByContentsId = new Map<number, DialogMonitor>();
const observedContentsIds = new Set<number>();
interface IpcHandlerRegistry {
@@ -24,6 +38,10 @@ interface WebContentsDebugger {
isAttached(): boolean;
attach(protocolVersion?: string): void;
sendCommand(command: string, params?: Record<string, unknown>): Promise<unknown>;
on?(
event: "message",
listener: (event: unknown, method: string, params?: Record<string, unknown>) => void,
): void;
}
interface ConsoleMessageEmitter {
@@ -60,6 +78,8 @@ interface BrowserAutomationWebContents extends ConsoleMessageEmitter {
export function adaptWebContents(contents: BrowserAutomationWebContents): TabContents {
observeConsoleMessages(contents);
const cdpQueue = getCdpQueue(contents.id);
const dialogMonitor = getDialogMonitor(contents, cdpQueue);
return {
id: contents.id,
getURL: () => contents.getURL(),
@@ -76,15 +96,27 @@ export function adaptWebContents(contents: BrowserAutomationWebContents): TabCon
capturePage: (captureOptions) => contents.capturePage(undefined, captureOptions),
invalidate: () => contents.invalidate(),
getConsoleMessages: () => consoleMessagesByContentsId.get(contents.id) ?? [],
sendDebugCommand: async (command: string, params?: Record<string, unknown>) => {
if (!contents.debugger.isAttached()) {
contents.debugger.attach("1.3");
}
return contents.debugger.sendCommand(command, params ?? {});
},
captureDialogs: (task) => dialogMonitor.capture(task),
sendDebugCommand: (command: string, params?: Record<string, unknown>) =>
cdpQueue.run(async () => {
if (!contents.debugger.isAttached()) {
contents.debugger.attach("1.3");
}
return contents.debugger.sendCommand(command, params ?? {});
}),
};
}
function getCdpQueue(contentsId: number): CdpSessionQueue {
const existing = cdpQueuesByContentsId.get(contentsId);
if (existing) {
return existing;
}
const queue = new CdpSessionQueue();
cdpQueuesByContentsId.set(contentsId, queue);
return queue;
}
function observeConsoleMessages(contents: BrowserAutomationWebContents): void {
if (observedContentsIds.has(contents.id)) {
return;
@@ -99,6 +131,192 @@ function observeConsoleMessages(contents: BrowserAutomationWebContents): void {
contents.once("destroyed", () => {
observedContentsIds.delete(contents.id);
consoleMessagesByContentsId.delete(contents.id);
cdpQueuesByContentsId.delete(contents.id);
dialogMonitorsByContentsId.delete(contents.id);
});
}
function getDialogMonitor(
contents: BrowserAutomationWebContents,
cdpQueue: CdpSessionQueue,
): DialogMonitor {
const existing = dialogMonitorsByContentsId.get(contents.id);
if (existing) {
return existing;
}
const monitor = new DialogMonitor(contents, cdpQueue);
dialogMonitorsByContentsId.set(contents.id, monitor);
return monitor;
}
class DialogMonitor {
private enabled = false;
private listenerRegistered = false;
private readonly activeCollectors: DialogCollector[] = [];
public constructor(
private readonly contents: BrowserAutomationWebContents,
private readonly cdpQueue: CdpSessionQueue,
) {}
public async capture<T>(
task: () => Promise<T>,
): Promise<{ result: T; dialogs: BrowserAutomationDialogEvent[] }> {
const collector: DialogCollector = { dialogs: [] };
try {
await this.enable();
await this.installPromptShim();
} catch (error) {
console.warn("[browser-automation] Dialog capture unavailable; running command without it", {
contentsId: this.contents.id,
error,
});
return { result: await task(), dialogs: [] };
}
this.activeCollectors.push(collector);
try {
const result = await task();
this.recordPromptShimDialogs(await this.drainPromptShim());
return { result, dialogs: collector.dialogs };
} finally {
const index = this.activeCollectors.indexOf(collector);
if (index >= 0) {
this.activeCollectors.splice(index, 1);
}
if (this.activeCollectors.length === 0) {
await this.restorePromptShim();
}
}
}
private async enable(): Promise<void> {
if (this.enabled) {
return;
}
if (!this.contents.debugger.on) {
return;
}
if (!this.listenerRegistered) {
this.listenerRegistered = true;
this.contents.debugger.on("message", (_event, method, params) => {
if (method !== "Page.javascriptDialogOpening") {
return;
}
if (this.activeCollectors.length === 0) {
return;
}
void this.handleOpening(params ?? {});
});
}
await this.sendDebugCommand("Page.enable");
this.enabled = true;
}
private async handleOpening(params: Record<string, unknown>): Promise<void> {
const event = handledDialogEvent(params);
for (const collector of this.activeCollectors) {
this.recordDialogs(collector, [event]);
}
await this.sendDialogResponseCommand("Page.handleJavaScriptDialog", {
accept: dialogAcceptValue(event.type),
});
}
private async installPromptShim(): Promise<void> {
await this.sendDebugCommand("Runtime.evaluate", {
expression: promptShimInstallScript(),
returnByValue: true,
});
}
private async drainPromptShim(): Promise<BrowserAutomationDialogEvent[]> {
try {
const result = (await this.sendDebugCommand("Runtime.evaluate", {
expression: promptShimDrainScript(),
returnByValue: true,
})) as { result?: { value?: unknown } };
return parsePromptShimDialogs(result.result?.value);
} catch {
return [];
}
}
private async restorePromptShim(): Promise<void> {
try {
await this.sendDebugCommand("Runtime.evaluate", {
expression: promptShimRestoreScript(),
returnByValue: true,
});
} catch {
// Navigation can destroy the execution context before cleanup runs; the next page has no shim.
}
}
private recordDialogs(collector: DialogCollector, dialogs: BrowserAutomationDialogEvent[]): void {
for (const dialog of dialogs) {
if (collector.dialogs.length >= MAX_DIALOGS_PER_COMMAND) {
return;
}
collector.dialogs.push(dialog);
}
}
private recordPromptShimDialogs(dialogs: BrowserAutomationDialogEvent[]): void {
for (const collector of this.activeCollectors) {
this.recordDialogs(collector, dialogs);
}
}
private async sendDebugCommand(
command: string,
params?: Record<string, unknown>,
): Promise<unknown> {
return this.cdpQueue.run(async () => {
if (!this.contents.debugger.isAttached()) {
this.contents.debugger.attach("1.3");
}
return this.contents.debugger.sendCommand(command, params ?? {});
});
}
private async sendDialogResponseCommand(
command: string,
params?: Record<string, unknown>,
): Promise<unknown> {
// Dialogs can block the CDP command that opened them, so the unblocker must not wait behind
// the per-tab command queue.
if (!this.contents.debugger.isAttached()) {
this.contents.debugger.attach("1.3");
}
return this.contents.debugger.sendCommand(command, params ?? {});
}
}
interface DialogCollector {
dialogs: BrowserAutomationDialogEvent[];
}
function parsePromptShimDialogs(value: unknown): BrowserAutomationDialogEvent[] {
if (!Array.isArray(value)) {
return [];
}
return value.flatMap((entry): BrowserAutomationDialogEvent[] => {
if (!entry || typeof entry !== "object") {
return [];
}
const record = entry as Record<string, unknown>;
if (record.type !== "prompt" || record.action !== "dismissed") {
return [];
}
return [
{
type: "prompt",
message: typeof record.message === "string" ? record.message : "",
...(typeof record.defaultValue === "string" ? { defaultValue: record.defaultValue } : {}),
action: "dismissed",
timestamp: typeof record.timestamp === "number" ? record.timestamp : Date.now(),
},
];
});
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -3,79 +3,162 @@ import { BrowserSnapshotEngine, type SnapshotPage } from "./snapshot-engine.js";
class SnapshotFixture implements SnapshotPage {
public currentUrl = "https://example.com/form";
public actionResult: unknown = true;
public actionResult: unknown = { ok: true };
public alreadyTruncated = false;
public snapshotNodes: unknown[] = [
{
kind: "role",
role: "heading",
name: "Settings",
tagName: "h1",
attributes: ["level=1"],
children: [],
},
{ kind: "text", text: "Connected as Maya" },
{
kind: "role",
role: "button",
name: "Save changes",
tagName: "button",
attributes: [],
ref: "@e1",
fingerprint: {
role: "button",
name: "Save changes",
tagName: "button",
type: "",
ariaLabel: "",
},
children: [],
},
];
public getURL(): string {
return this.currentUrl;
}
public async executeJavaScript(code: string): Promise<unknown> {
if (code.includes("CANDIDATE_SELECTOR")) {
return JSON.stringify([
{
role: "textbox",
tagName: "input",
text: "Name",
selector: "#name",
attributes: { id: "name", type: "text" },
if (code.includes("__PASEO_ARIA_SNAPSHOT__")) {
return JSON.stringify({
marker: "__PASEO_ARIA_SNAPSHOT__",
root: {
kind: "role",
role: "document",
name: "Fixture",
tagName: "document",
attributes: [],
children: this.snapshotNodes,
},
{
role: "button",
tagName: "button",
text: "Drop",
selector: "#drop",
attributes: { id: "drop" },
},
]);
refs: [
{
ref: "@e1",
fingerprint: {
role: "button",
name: "Save changes",
tagName: "button",
type: "",
ariaLabel: "",
},
},
],
truncated: this.alreadyTruncated,
stats: { nodeCount: 4, refCount: 1, textLength: 0, iframeCount: 0, maxDepth: 1 },
});
}
return this.actionResult;
}
}
describe("BrowserSnapshotEngine", () => {
it("treats a false result from a ref action script as a stale ref", async () => {
it("renders a hierarchical ARIA YAML snapshot with static text and actionable refs", async () => {
const page = new SnapshotFixture();
const engine = new BrowserSnapshotEngine();
await engine.snapshot({ browserId: "browser-1", page });
page.actionResult = false;
await expect(engine.click({ browserId: "browser-1", page, ref: "@e1" })).resolves.toEqual({
ok: false,
reason: "stale_ref",
});
await expect(
engine.select({ browserId: "browser-1", page, ref: "@e1", value: "us" }),
).resolves.toEqual({
ok: false,
reason: "stale_ref",
await expect(engine.snapshot({ browserId: "browser-1", page })).resolves.toEqual({
format: "aria-yaml",
snapshot: [
'- document "Fixture"',
' - heading "Settings" [level=1]',
' - text: "Connected as Maya"',
' - button "Save changes" [ref=@e1]',
].join("\n"),
truncated: false,
stats: { nodeCount: 4, refCount: 1, textLength: 119, iframeCount: 0, maxDepth: 1 },
});
});
it("treats a false result from optional ref text/key actions as a stale ref", async () => {
it("builds a runtime ref expression with the snapshot fingerprint", async () => {
const page = new SnapshotFixture();
const engine = new BrowserSnapshotEngine();
await engine.snapshot({ browserId: "browser-1", page });
page.actionResult = false;
page.currentUrl = "https://example.com/form?panel=advanced";
await expect(
engine.typeText({ browserId: "browser-1", page, ref: "@e1", text: "Ada" }),
).resolves.toEqual({ ok: false, reason: "stale_ref" });
await expect(
engine.keypress({ browserId: "browser-1", page, ref: "@e1", key: "Enter" }),
).resolves.toEqual({ ok: false, reason: "stale_ref" });
expect(engine.runtimeElementExpression({ browserId: "browser-1", ref: "@e1" })).toContain(
'"name":"Save changes"',
);
});
it("treats a false result from drag as a stale ref", async () => {
it("treats missing host-side ref metadata as a stale ref", async () => {
const page = new SnapshotFixture();
const engine = new BrowserSnapshotEngine();
await engine.snapshot({ browserId: "browser-1", page });
page.actionResult = false;
expect(engine.runtimeElementExpression({ browserId: "browser-1", ref: "@e2" })).toEqual({
ok: false,
reason: "missing_ref",
});
});
await expect(
engine.drag({ browserId: "browser-1", page, sourceRef: "@e1", targetRef: "@e2" }),
).resolves.toEqual({ ok: false, reason: "stale_ref" });
it("marks rendered output truncation explicitly and deterministically", async () => {
const page = new SnapshotFixture();
page.snapshotNodes = [
{
kind: "text",
text: "A".repeat(81_000),
},
];
const engine = new BrowserSnapshotEngine();
const snapshot = await engine.snapshot({ browserId: "browser-1", page });
expect(snapshot.truncated).toBe(true);
expect(snapshot.snapshot.endsWith('- text: "Snapshot truncated."')).toBe(true);
expect(snapshot.stats.textLength).toBeLessThanOrEqual(80_000);
});
it("keeps the last rendered node when a short snapshot was capped by node count", async () => {
const page = new SnapshotFixture();
page.alreadyTruncated = true;
page.snapshotNodes = [
{
kind: "role",
role: "button",
name: "Final action",
tagName: "button",
attributes: [],
ref: "@e1",
fingerprint: {
role: "button",
name: "Final action",
tagName: "button",
type: "",
ariaLabel: "",
},
children: [],
},
];
const engine = new BrowserSnapshotEngine();
const snapshot = await engine.snapshot({ browserId: "browser-1", page });
expect(snapshot.truncated).toBe(true);
expect(snapshot.snapshot).toBe(
[
'- document "Fixture"',
' - button "Final action" [ref=@e1]',
'- text: "Snapshot truncated."',
].join("\n"),
);
});
});
@@ -1,24 +1,60 @@
import { ARIA_SNAPSHOT_SCRIPT, ARIA_SNAPSHOT_SCRIPT_MARKER } from "./aria-snapshot-script.js";
export interface SnapshotPage {
getURL(): string;
executeJavaScript(code: string): Promise<unknown>;
}
export interface BrowserSnapshotElement extends RawSnapshotElement {
ref: string;
export interface BrowserAriaSnapshot {
format: "aria-yaml";
snapshot: string;
truncated: boolean;
stats: BrowserAriaSnapshotStats;
}
interface RawSnapshotElement {
export interface BrowserAriaSnapshotStats {
nodeCount: number;
refCount: number;
textLength: number;
iframeCount?: number;
maxDepth?: number;
}
interface SnapshotNode {
kind: "role" | "text" | "group";
role?: string;
name?: string;
text?: string;
tagName?: string;
attributes?: string[];
ref?: string;
fingerprint?: BrowserRefFingerprint;
children?: SnapshotNode[];
}
interface BrowserRefFingerprint {
role: string;
name: string;
tagName: string;
text: string;
selector: string;
attributes: Record<string, string>;
type: string;
ariaLabel: string;
}
interface RawAriaSnapshot {
marker: string;
root: SnapshotNode;
refs: BrowserRefMetadata[];
truncated: boolean;
stats: BrowserAriaSnapshotStats;
}
interface BrowserRefMetadata {
ref: string;
fingerprint: BrowserRefFingerprint;
}
interface BrowserRefState {
nextRefNumber: number;
url: string;
refs: Map<string, RawSnapshotElement>;
refs: Map<string, BrowserRefMetadata>;
}
export type BrowserRefActionResult =
@@ -26,45 +62,31 @@ export type BrowserRefActionResult =
| { ok: false; reason: "stale_ref" | "missing_ref" };
type BrowserRefFailure = Extract<BrowserRefActionResult, { ok: false }>;
type BrowserRefResolveResult = { ok: true; element: RawSnapshotElement } | BrowserRefFailure;
type BrowserRefResolveResult = { ok: true; metadata: BrowserRefMetadata } | BrowserRefFailure;
const TRUNCATION_MARKER = '- text: "Snapshot truncated."';
const MAX_RENDERED_TEXT_LENGTH = 80_000;
export class BrowserSnapshotEngine {
private readonly statesByBrowserId = new Map<string, BrowserRefState>();
async snapshot(input: {
browserId: string;
page: SnapshotPage;
}): Promise<BrowserSnapshotElement[]> {
const rawElements = parseRawSnapshotElements(
await input.page.executeJavaScript(SNAPSHOT_SCRIPT),
);
const state = {
nextRefNumber: 1,
url: input.page.getURL(),
refs: new Map<string, RawSnapshotElement>(),
};
const elements = rawElements.map((element) => {
const ref = `@e${state.nextRefNumber++}`;
state.refs.set(ref, element);
return {
ref,
role: element.role,
tagName: element.tagName,
text: element.text,
selector: element.selector,
attributes: element.attributes,
};
async snapshot(input: { browserId: string; page: SnapshotPage }): Promise<BrowserAriaSnapshot> {
const rawSnapshot = parseAriaSnapshot(await input.page.executeJavaScript(ARIA_SNAPSHOT_SCRIPT));
const rendered = renderSnapshot(rawSnapshot.root);
const capped = capRenderedSnapshot(rendered, rawSnapshot.truncated);
this.statesByBrowserId.set(input.browserId, {
refs: new Map(rawSnapshot.refs.map((ref) => [ref.ref, ref])),
});
this.statesByBrowserId.set(input.browserId, state);
return elements;
}
async click(input: {
browserId: string;
page: SnapshotPage;
ref: string;
}): Promise<BrowserRefActionResult> {
return this.runRefScript(input, (selector) => buildClickScript(selector));
return {
format: "aria-yaml",
snapshot: capped.snapshot,
truncated: capped.truncated,
stats: {
...rawSnapshot.stats,
refCount: rawSnapshot.refs.length,
textLength: capped.snapshot.length,
},
};
}
async fill(input: {
@@ -73,39 +95,7 @@ export class BrowserSnapshotEngine {
ref: string;
value: string;
}): Promise<BrowserRefActionResult> {
return this.runRefScript(input, (selector) => buildFillScript(selector, input.value));
}
async typeText(input: {
browserId: string;
page: SnapshotPage;
ref?: string;
text: string;
}): Promise<BrowserRefActionResult> {
const selector = this.resolveOptionalRef(input);
if (!selector.ok) {
return selector;
}
const result = await input.page.executeJavaScript(
buildTypeScript(selector.selector, input.text),
);
return input.ref && result === false ? { ok: false, reason: "stale_ref" } : { ok: true };
}
async keypress(input: {
browserId: string;
page: SnapshotPage;
ref?: string;
key: string;
}): Promise<BrowserRefActionResult> {
const selector = this.resolveOptionalRef(input);
if (!selector.ok) {
return selector;
}
const result = await input.page.executeJavaScript(
buildKeypressScript(selector.selector, input.key),
);
return input.ref && result === false ? { ok: false, reason: "stale_ref" } : { ok: true };
return this.runRefScript(input, (ref) => buildFillScript(ref, input.value));
}
async select(input: {
@@ -114,123 +104,214 @@ export class BrowserSnapshotEngine {
ref: string;
value: string;
}): Promise<BrowserRefActionResult> {
return this.runRefScript(input, (selector) => buildSelectScript(selector, input.value));
}
async hover(input: {
browserId: string;
page: SnapshotPage;
ref: string;
}): Promise<BrowserRefActionResult> {
return this.runRefScript(input, (selector) => buildHoverScript(selector));
}
async drag(input: {
browserId: string;
page: SnapshotPage;
sourceRef: string;
targetRef: string;
}): Promise<BrowserRefActionResult> {
const source = this.resolveRef({
browserId: input.browserId,
page: input.page,
ref: input.sourceRef,
});
if (!source.ok) {
return source;
}
const target = this.resolveRef({
browserId: input.browserId,
page: input.page,
ref: input.targetRef,
});
if (!target.ok) {
return target;
}
const result = await input.page.executeJavaScript(
buildDragScript(source.element.selector, target.element.selector),
);
return result === false ? { ok: false, reason: "stale_ref" } : { ok: true };
return this.runRefScript(input, (ref) => buildSelectScript(ref, input.value));
}
clearBrowser(browserId: string): void {
this.statesByBrowserId.delete(browserId);
}
selectorForRef(input: {
browserId: string;
page: SnapshotPage;
ref: string;
}): { ok: true; selector: string } | BrowserRefFailure {
runtimeElementExpression(input: { browserId: string; ref: string }): string | BrowserRefFailure {
const resolved = this.resolveRef(input);
if (!resolved.ok) {
return resolved;
}
return { ok: true, selector: resolved.element.selector };
return buildRuntimeElementExpression(resolved.metadata);
}
private async runRefScript(
input: { browserId: string; page: SnapshotPage; ref: string },
buildScript: (selector: string) => string,
buildScript: (ref: BrowserRefMetadata) => string,
): Promise<BrowserRefActionResult> {
const resolved = this.resolveRef(input);
if (!resolved.ok) {
return resolved;
}
const result = await input.page.executeJavaScript(buildScript(resolved.element.selector));
return result === false ? { ok: false, reason: "stale_ref" } : { ok: true };
const result = await input.page.executeJavaScript(buildScript(resolved.metadata));
return readActionResult(result, true);
}
private resolveRef(input: {
browserId: string;
page: SnapshotPage;
ref: string;
}): BrowserRefResolveResult {
private resolveRef(input: { browserId: string; ref: string }): BrowserRefResolveResult {
const state = this.statesByBrowserId.get(input.browserId);
if (!state || state.url !== input.page.getURL()) {
if (!state) {
return { ok: false, reason: "stale_ref" };
}
const element = state.refs.get(input.ref);
if (!element) {
const metadata = state.refs.get(input.ref);
if (!metadata) {
return { ok: false, reason: "missing_ref" };
}
return { ok: true, element };
}
private resolveOptionalRef(input: {
browserId: string;
page: SnapshotPage;
ref?: string;
}): { ok: true; selector: string | undefined } | BrowserRefFailure {
if (!input.ref) {
return { ok: true, selector: undefined };
}
const resolved = this.resolveRef({
browserId: input.browserId,
page: input.page,
ref: input.ref,
});
if (!resolved.ok) {
return resolved;
}
return { ok: true, selector: resolved.element.selector };
return { ok: true, metadata };
}
}
function buildClickScript(selector: string): string {
return String.raw`(() => {
const element = document.querySelector(${JSON.stringify(selector)});
if (!element) return false;
element.scrollIntoView({ block: 'center', inline: 'center' });
element.click();
return true;
})()`;
function readActionResult(value: unknown, staleWhenFalse: boolean): BrowserRefActionResult {
if (value === false && staleWhenFalse) {
return { ok: false, reason: "stale_ref" };
}
if (isActionFailure(value)) {
return { ok: false, reason: value.reason };
}
return { ok: true };
}
function buildFillScript(selector: string, value: string): string {
function isActionFailure(value: unknown): value is BrowserRefFailure {
if (!value || typeof value !== "object") {
return false;
}
const reason = (value as Record<string, unknown>).reason;
return reason === "stale_ref" || reason === "missing_ref";
}
function parseAriaSnapshot(value: unknown): RawAriaSnapshot {
const parsed = typeof value === "string" ? JSON.parse(value) : value;
if (!parsed || typeof parsed !== "object") {
return emptySnapshot();
}
const record = parsed as Record<string, unknown>;
if (record.marker !== ARIA_SNAPSHOT_SCRIPT_MARKER) {
return emptySnapshot();
}
const root = parseSnapshotNode(record.root);
return {
marker: ARIA_SNAPSHOT_SCRIPT_MARKER,
root: root ?? emptySnapshot().root,
refs: parseRefs(record.refs),
truncated: record.truncated === true,
stats: parseStats(record.stats),
};
}
function emptySnapshot(): RawAriaSnapshot {
return {
marker: ARIA_SNAPSHOT_SCRIPT_MARKER,
root: { kind: "role", role: "document", name: "", tagName: "document", children: [] },
refs: [],
truncated: false,
stats: { nodeCount: 0, refCount: 0, textLength: 0, iframeCount: 0, maxDepth: 0 },
};
}
function parseSnapshotNode(value: unknown): SnapshotNode | null {
if (!value || typeof value !== "object") {
return null;
}
const record = value as Record<string, unknown>;
const kind = record.kind;
if (kind !== "role" && kind !== "text" && kind !== "group") {
return null;
}
return {
kind,
...(readString(record.role) ? { role: readString(record.role) ?? undefined } : {}),
...(readString(record.name) ? { name: readString(record.name) ?? undefined } : {}),
...(readString(record.text) ? { text: readString(record.text) ?? undefined } : {}),
...(readString(record.tagName) ? { tagName: readString(record.tagName) ?? undefined } : {}),
...(readString(record.ref) ? { ref: readString(record.ref) ?? undefined } : {}),
...(parseFingerprint(record.fingerprint)
? { fingerprint: parseFingerprint(record.fingerprint) ?? undefined }
: {}),
attributes: readStringArray(record.attributes),
children: Array.isArray(record.children)
? record.children.flatMap((child): SnapshotNode[] => {
const parsed = parseSnapshotNode(child);
return parsed ? [parsed] : [];
})
: [],
};
}
function parseRefs(value: unknown): BrowserRefMetadata[] {
if (!Array.isArray(value)) {
return [];
}
return value.flatMap((item): BrowserRefMetadata[] => {
if (!item || typeof item !== "object") {
return [];
}
const record = item as Record<string, unknown>;
const ref = readString(record.ref);
const fingerprint = parseFingerprint(record.fingerprint);
return ref && fingerprint ? [{ ref, fingerprint }] : [];
});
}
function parseFingerprint(value: unknown): BrowserRefFingerprint | null {
if (!value || typeof value !== "object") {
return null;
}
const record = value as Record<string, unknown>;
const role = readString(record.role);
const name = readString(record.name);
const tagName = readString(record.tagName);
const type = readString(record.type);
const ariaLabel = readString(record.ariaLabel);
if (role === null || name === null || tagName === null || type === null || ariaLabel === null) {
return null;
}
return { role, name, tagName, type, ariaLabel };
}
function parseStats(value: unknown): BrowserAriaSnapshotStats {
if (!value || typeof value !== "object") {
return { nodeCount: 0, refCount: 0, textLength: 0 };
}
const record = value as Record<string, unknown>;
return {
nodeCount: readNumber(record.nodeCount) ?? 0,
refCount: readNumber(record.refCount) ?? 0,
textLength: readNumber(record.textLength) ?? 0,
...(readNumber(record.iframeCount) !== null
? { iframeCount: readNumber(record.iframeCount) ?? undefined }
: {}),
...(readNumber(record.maxDepth) !== null
? { maxDepth: readNumber(record.maxDepth) ?? undefined }
: {}),
};
}
function renderSnapshot(root: SnapshotNode): string {
const lines = renderNode(root, 0);
return lines.length > 0 ? lines.join("\n") : "- document";
}
function renderNode(node: SnapshotNode, depth: number): string[] {
if (node.kind === "group") {
return (node.children ?? []).flatMap((child) => renderNode(child, depth));
}
const indent = " ".repeat(depth);
if (node.kind === "text") {
return [`${indent}- text: ${JSON.stringify(node.text ?? "")}`];
}
const attrs = [...(node.attributes ?? [])];
if (node.ref) {
attrs.push(`ref=${node.ref}`);
}
const suffix = attrs.length > 0 ? ` [${attrs.join(" ")}]` : "";
const ownLine = `${indent}- ${node.role ?? "generic"}${node.name ? ` ${JSON.stringify(node.name)}` : ""}${suffix}`;
const childLines = (node.children ?? []).flatMap((child) => renderNode(child, depth + 1));
return [ownLine, ...childLines];
}
function capRenderedSnapshot(
snapshot: string,
alreadyTruncated: boolean,
): { snapshot: string; truncated: boolean } {
if (snapshot.length <= MAX_RENDERED_TEXT_LENGTH && !alreadyTruncated) {
return { snapshot, truncated: false };
}
if (snapshot.length + 1 + TRUNCATION_MARKER.length <= MAX_RENDERED_TEXT_LENGTH) {
return { snapshot: `${snapshot}\n${TRUNCATION_MARKER}`, truncated: true };
}
const availableLength = MAX_RENDERED_TEXT_LENGTH - TRUNCATION_MARKER.length - 1;
const capped = snapshot.slice(0, Math.max(0, availableLength)).replace(/\n[^\n]*$/, "");
return { snapshot: `${capped}\n${TRUNCATION_MARKER}`, truncated: true };
}
function buildFillScript(metadata: BrowserRefMetadata, value: string): string {
return String.raw`(() => {
const element = document.querySelector(${JSON.stringify(selector)});
if (!element) return false;
const resolved = ${buildResolveExpression(metadata)};
if (!resolved.ok) return resolved;
const element = resolved.element;
element.scrollIntoView({ block: 'center', inline: 'center' });
element.focus();
const nextValue = ${JSON.stringify(value)};
@@ -238,51 +319,19 @@ function buildFillScript(selector: string, value: string): string {
element.value = nextValue;
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
return true;
return { ok: true };
}
element.textContent = nextValue;
element.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: nextValue }));
return true;
return { ok: true };
})()`;
}
function buildTypeScript(selector: string | undefined, text: string): string {
function buildSelectScript(metadata: BrowserRefMetadata, value: string): string {
return String.raw`(() => {
const element = ${selector ? `document.querySelector(${JSON.stringify(selector)})` : "document.activeElement"};
if (!element) return false;
element.scrollIntoView?.({ block: 'center', inline: 'center' });
element.focus?.();
const text = ${JSON.stringify(text)};
if ('value' in element) {
element.value = String(element.value || '') + text;
element.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: text }));
element.dispatchEvent(new Event('change', { bubbles: true }));
return true;
}
element.textContent = String(element.textContent || '') + text;
element.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: text }));
return true;
})()`;
}
function buildKeypressScript(selector: string | undefined, key: string): string {
return String.raw`(() => {
const element = ${selector ? `document.querySelector(${JSON.stringify(selector)})` : "document.activeElement"};
if (!element) return false;
element.focus?.();
const key = ${JSON.stringify(key)};
const eventInit = { bubbles: true, cancelable: true, key };
element.dispatchEvent(new KeyboardEvent('keydown', eventInit));
element.dispatchEvent(new KeyboardEvent('keypress', eventInit));
element.dispatchEvent(new KeyboardEvent('keyup', eventInit));
return true;
})()`;
}
function buildSelectScript(selector: string, value: string): string {
return String.raw`(() => {
const element = document.querySelector(${JSON.stringify(selector)});
if (!element) return false;
const resolved = ${buildResolveExpression(metadata)};
if (!resolved.ok) return resolved;
const element = resolved.element;
element.scrollIntoView?.({ block: 'center', inline: 'center' });
element.focus?.();
const nextValue = ${JSON.stringify(value)};
@@ -290,205 +339,34 @@ function buildSelectScript(selector: string, value: string): string {
element.value = nextValue;
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
return true;
return { ok: true };
}
return false;
})()`;
}
function buildHoverScript(selector: string): string {
function buildRuntimeElementExpression(metadata: BrowserRefMetadata): string {
return String.raw`(() => {
const element = document.querySelector(${JSON.stringify(selector)});
if (!element) return false;
element.scrollIntoView?.({ block: 'center', inline: 'center' });
const rect = element.getBoundingClientRect();
const eventInit = {
bubbles: true,
cancelable: true,
clientX: rect.left + rect.width / 2,
clientY: rect.top + rect.height / 2,
screenX: window.screenX + rect.left + rect.width / 2,
screenY: window.screenY + rect.top + rect.height / 2,
view: window,
};
element.dispatchEvent(new MouseEvent('mouseover', eventInit));
element.dispatchEvent(new MouseEvent('mouseenter', eventInit));
element.dispatchEvent(new MouseEvent('mousemove', eventInit));
return true;
const resolved = ${buildResolveExpression(metadata)};
if (!resolved.ok) return null;
return resolved.element;
})()`;
}
function buildDragScript(sourceSelector: string, targetSelector: string): string {
return String.raw`(() => {
const source = document.querySelector(${JSON.stringify(sourceSelector)});
const target = document.querySelector(${JSON.stringify(targetSelector)});
if (!source || !target) return false;
source.scrollIntoView?.({ block: 'center', inline: 'center' });
target.scrollIntoView?.({ block: 'center', inline: 'center' });
const data = new DataTransfer();
const sourceRect = source.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
function eventInit(rect) {
return {
bubbles: true,
cancelable: true,
clientX: rect.left + rect.width / 2,
clientY: rect.top + rect.height / 2,
screenX: window.screenX + rect.left + rect.width / 2,
screenY: window.screenY + rect.top + rect.height / 2,
dataTransfer: data,
view: window,
};
}
source.dispatchEvent(new MouseEvent('mousedown', eventInit(sourceRect)));
source.dispatchEvent(new DragEvent('dragstart', eventInit(sourceRect)));
target.dispatchEvent(new DragEvent('dragenter', eventInit(targetRect)));
target.dispatchEvent(new DragEvent('dragover', eventInit(targetRect)));
target.dispatchEvent(new DragEvent('drop', eventInit(targetRect)));
source.dispatchEvent(new DragEvent('dragend', eventInit(sourceRect)));
target.dispatchEvent(new MouseEvent('mouseup', eventInit(targetRect)));
return true;
})()`;
}
function parseRawSnapshotElements(value: unknown): RawSnapshotElement[] {
const parsed = typeof value === "string" ? JSON.parse(value) : value;
if (!Array.isArray(parsed)) {
return [];
}
return parsed.flatMap((item): RawSnapshotElement[] => {
if (!item || typeof item !== "object") {
return [];
}
const record = item as Record<string, unknown>;
const selector = readString(record.selector);
if (!selector) {
return [];
}
return [
{
role: readString(record.role) || "generic",
tagName: (readString(record.tagName) || "element").toLowerCase(),
text: readString(record.text) || "",
selector,
attributes: readAttributes(record.attributes),
},
];
});
function buildResolveExpression(metadata: BrowserRefMetadata): string {
return `window.__PASEO_BROWSER_AUTOMATION__?.resolve(${JSON.stringify(metadata.ref)}, ${JSON.stringify(metadata.fingerprint)}) ?? { ok: false, reason: 'stale_ref' }`;
}
function readString(value: unknown): string | null {
return typeof value === "string" ? value : null;
}
function readAttributes(value: unknown): Record<string, string> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return {};
}
const result: Record<string, string> = {};
for (const [key, attributeValue] of Object.entries(value)) {
if (typeof attributeValue === "string") {
result[key] = attributeValue;
}
}
return result;
function readStringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === "string")
: [];
}
const SNAPSHOT_SCRIPT = String.raw`(() => {
const MAX_ELEMENTS = 200;
const CANDIDATE_SELECTOR = [
'a[href]',
'button',
'input',
'textarea',
'select',
'summary',
'[role]',
'[tabindex]:not([tabindex="-1"])',
'[contenteditable=""]',
'[contenteditable="true"]'
].join(',');
function cssEscape(value) {
if (window.CSS && typeof window.CSS.escape === 'function') {
return window.CSS.escape(value);
}
return String(value).replace(/[^a-zA-Z0-9_-]/g, '\\$&');
}
function isVisible(element) {
const style = window.getComputedStyle(element);
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
return false;
}
const rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
function roleFor(element) {
const explicit = element.getAttribute('role');
if (explicit) return explicit;
const tag = element.tagName.toLowerCase();
if (tag === 'a') return 'link';
if (tag === 'button') return 'button';
if (tag === 'select') return 'combobox';
if (tag === 'textarea') return 'textbox';
if (tag === 'summary') return 'button';
if (tag === 'input') {
const type = (element.getAttribute('type') || 'text').toLowerCase();
if (type === 'checkbox') return 'checkbox';
if (type === 'radio') return 'radio';
if (type === 'button' || type === 'submit' || type === 'reset') return 'button';
return 'textbox';
}
return 'generic';
}
function textFor(element) {
const tag = element.tagName.toLowerCase();
const pieces = [
element.getAttribute('aria-label'),
element.getAttribute('alt'),
element.getAttribute('title'),
tag === 'input' ? element.getAttribute('placeholder') : null,
tag === 'input' || tag === 'textarea' ? element.value : null,
element.innerText,
element.textContent
];
const text = pieces.find((piece) => typeof piece === 'string' && piece.trim().length > 0);
return (text || '').replace(/\s+/g, ' ').trim().slice(0, 300);
}
function selectorFor(element) {
if (element.id) return '#' + cssEscape(element.id);
const parts = [];
let current = element;
while (current && current.nodeType === Node.ELEMENT_NODE && current !== document.body) {
const tag = current.tagName.toLowerCase();
const parent = current.parentElement;
if (!parent) break;
const siblings = Array.from(parent.children).filter((sibling) => sibling.tagName === current.tagName);
const index = siblings.indexOf(current) + 1;
parts.unshift(siblings.length > 1 ? tag + ':nth-of-type(' + index + ')' : tag);
current = parent;
}
return parts.length > 0 ? parts.join(' > ') : element.tagName.toLowerCase();
}
return JSON.stringify(Array.from(document.querySelectorAll(CANDIDATE_SELECTOR))
.filter(isVisible)
.slice(0, MAX_ELEMENTS)
.map((element) => ({
role: roleFor(element),
tagName: element.tagName.toLowerCase(),
text: textFor(element),
selector: selectorFor(element),
attributes: {
...(element.id ? { id: element.id } : {}),
...(element.getAttribute('name') ? { name: element.getAttribute('name') } : {}),
...(element.getAttribute('type') ? { type: element.getAttribute('type') } : {}),
...(element.getAttribute('href') ? { href: element.getAttribute('href') } : {}),
...(element.getAttribute('aria-label') ? { 'aria-label': element.getAttribute('aria-label') } : {})
}
})));
})()`;
function readNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
@@ -0,0 +1,38 @@
import { describe, expect, test } from "vitest";
import { dispatchTrustedKey } from "./trusted-input.js";
describe("trusted browser input", () => {
test("Space dispatches a real space key event", async () => {
const commands: Array<{ command: string; params?: Record<string, unknown> }> = [];
await dispatchTrustedKey(async (command, params) => {
commands.push({ command, ...(params ? { params } : {}) });
return {};
}, "Space");
expect(commands).toEqual([
{
command: "Input.dispatchKeyEvent",
params: {
type: "keyDown",
key: " ",
code: "Space",
windowsVirtualKeyCode: 32,
nativeVirtualKeyCode: 32,
text: " ",
unmodifiedText: " ",
},
},
{
command: "Input.dispatchKeyEvent",
params: {
type: "keyUp",
key: " ",
code: "Space",
windowsVirtualKeyCode: 32,
nativeVirtualKeyCode: 32,
},
},
]);
});
});
@@ -0,0 +1,217 @@
import type { ActionablePoint } from "./actionability.js";
import type { CdpCommandSender } from "./cdp-session-queue.js";
export type MouseButton = "left" | "right" | "middle";
export type InputModifier = "Alt" | "Control" | "Meta" | "Shift";
export interface ClickInputOptions {
button?: MouseButton;
doubleClick?: boolean;
modifiers?: InputModifier[];
}
const MODIFIER_MASKS: Record<InputModifier, number> = {
Alt: 1,
Control: 2,
Meta: 4,
Shift: 8,
};
const SPECIAL_KEY_DEFINITIONS: Record<
string,
{ key: string; code: string; windowsVirtualKeyCode: number; text?: string }
> = {
Enter: { key: "Enter", code: "Enter", windowsVirtualKeyCode: 13, text: "\r" },
Space: { key: " ", code: "Space", windowsVirtualKeyCode: 32, text: " " },
Tab: { key: "Tab", code: "Tab", windowsVirtualKeyCode: 9, text: "\t" },
Escape: { key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 },
Backspace: { key: "Backspace", code: "Backspace", windowsVirtualKeyCode: 8 },
Delete: { key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 },
ArrowUp: { key: "ArrowUp", code: "ArrowUp", windowsVirtualKeyCode: 38 },
ArrowDown: { key: "ArrowDown", code: "ArrowDown", windowsVirtualKeyCode: 40 },
ArrowLeft: { key: "ArrowLeft", code: "ArrowLeft", windowsVirtualKeyCode: 37 },
ArrowRight: { key: "ArrowRight", code: "ArrowRight", windowsVirtualKeyCode: 39 },
Home: { key: "Home", code: "Home", windowsVirtualKeyCode: 36 },
End: { key: "End", code: "End", windowsVirtualKeyCode: 35 },
PageUp: { key: "PageUp", code: "PageUp", windowsVirtualKeyCode: 33 },
PageDown: { key: "PageDown", code: "PageDown", windowsVirtualKeyCode: 34 },
};
export async function dispatchTrustedClick(
send: CdpCommandSender,
point: ActionablePoint,
options: ClickInputOptions = {},
): Promise<void> {
const button = options.button ?? "left";
const modifiers = modifierMask(options.modifiers);
await send("Input.dispatchMouseEvent", {
type: "mouseMoved",
x: point.x,
y: point.y,
button: "none",
modifiers,
});
if (options.doubleClick) {
await dispatchTrustedMouseClick(send, point, button, modifiers, 1);
await dispatchTrustedMouseClick(send, point, button, modifiers, 2);
return;
}
await dispatchTrustedMouseClick(send, point, button, modifiers, 1);
}
async function dispatchTrustedMouseClick(
send: CdpCommandSender,
point: ActionablePoint,
button: MouseButton,
modifiers: number,
clickCount: number,
): Promise<void> {
await send("Input.dispatchMouseEvent", {
type: "mousePressed",
x: point.x,
y: point.y,
button,
buttons: mouseButtonMask(button),
clickCount,
modifiers,
});
await send("Input.dispatchMouseEvent", {
type: "mouseReleased",
x: point.x,
y: point.y,
button,
buttons: 0,
clickCount,
modifiers,
});
}
export async function dispatchTrustedHover(
send: CdpCommandSender,
point: ActionablePoint,
): Promise<void> {
await send("Input.dispatchMouseEvent", {
type: "mouseMoved",
x: point.x,
y: point.y,
button: "none",
});
}
export async function dispatchTrustedDrag(
send: CdpCommandSender,
source: ActionablePoint,
target: ActionablePoint,
): Promise<void> {
await send("Input.dispatchMouseEvent", {
type: "mouseMoved",
x: source.x,
y: source.y,
button: "none",
});
await send("Input.dispatchMouseEvent", {
type: "mousePressed",
x: source.x,
y: source.y,
button: "left",
buttons: 1,
clickCount: 1,
});
await send("Input.dispatchMouseEvent", {
type: "mouseMoved",
x: (source.x + target.x) / 2,
y: (source.y + target.y) / 2,
button: "left",
buttons: 1,
});
await send("Input.dispatchMouseEvent", {
type: "mouseMoved",
x: target.x,
y: target.y,
button: "left",
buttons: 1,
});
await send("Input.dispatchMouseEvent", {
type: "mouseReleased",
x: target.x,
y: target.y,
button: "left",
buttons: 0,
clickCount: 1,
});
}
export async function dispatchTrustedScroll(
send: CdpCommandSender,
point: ActionablePoint,
deltaX: number,
deltaY: number,
): Promise<void> {
await send("Input.dispatchMouseEvent", {
type: "mouseWheel",
x: point.x,
y: point.y,
deltaX,
deltaY,
});
}
export async function dispatchTrustedText(send: CdpCommandSender, text: string): Promise<void> {
if (text.length === 0) {
return;
}
await send("Input.insertText", { text });
}
export async function dispatchTrustedKey(send: CdpCommandSender, key: string): Promise<void> {
const definition = keyDefinition(key);
await send("Input.dispatchKeyEvent", {
type: "keyDown",
key: definition.key,
code: definition.code,
windowsVirtualKeyCode: definition.windowsVirtualKeyCode,
nativeVirtualKeyCode: definition.windowsVirtualKeyCode,
...(definition.text ? { text: definition.text, unmodifiedText: definition.text } : {}),
});
await send("Input.dispatchKeyEvent", {
type: "keyUp",
key: definition.key,
code: definition.code,
windowsVirtualKeyCode: definition.windowsVirtualKeyCode,
nativeVirtualKeyCode: definition.windowsVirtualKeyCode,
});
}
function keyDefinition(key: string): {
key: string;
code: string;
windowsVirtualKeyCode: number;
text?: string;
} {
const special = SPECIAL_KEY_DEFINITIONS[key];
if (special) {
return special;
}
const text = key.length === 1 ? key : "";
const upper = text.toUpperCase();
return {
key,
code: upper ? `Key${upper}` : key,
windowsVirtualKeyCode: upper ? upper.charCodeAt(0) : 0,
...(text ? { text, unmodifiedText: text } : {}),
};
}
function modifierMask(modifiers: InputModifier[] | undefined): number {
return (modifiers ?? []).reduce((mask, modifier) => mask | MODIFIER_MASKS[modifier], 0);
}
function mouseButtonMask(button: MouseButton): number {
if (button === "right") {
return 2;
}
if (button === "middle") {
return 4;
}
return 1;
}
@@ -70,6 +70,10 @@ export function registerPaseoBrowserWorkspace(input: BrowserWorkspaceRegistratio
browserRegistry.registerWorkspace(input);
}
export function unregisterPaseoBrowser(browserId: string): void {
browserRegistry.unregisterBrowser(browserId);
}
export function getPaseoBrowserWorkspaceId(browserId: string): string | null {
return browserRegistry.getWorkspaceId(browserId);
}
@@ -51,6 +51,16 @@ export class PaseoBrowserWebviewRegistry {
this.workspaceIdsByBrowserId.set(input.browserId, input.workspaceId);
}
public unregisterBrowser(browserId: string): void {
const webContentsId = this.webContentsIdsByBrowserId.get(browserId) ?? null;
if (webContentsId !== null) {
this.browserIdsByWebContentsId.delete(webContentsId);
this.webContentsIdsByBrowserId.delete(browserId);
}
this.workspaceIdsByBrowserId.delete(browserId);
this.deleteActiveBrowserReferences(browserId);
}
public getWorkspaceId(browserId: string): string | null {
return this.workspaceIdsByBrowserId.get(browserId) ?? null;
}
+7
View File
@@ -54,6 +54,7 @@ import {
listRegisteredPaseoBrowserIds,
readBrowserIdFromWebviewAttach,
registerBrowserWebviewNavigationGuards,
unregisterPaseoBrowser,
registerPaseoBrowserWorkspace,
registerPaseoBrowserWebContents,
setWorkspaceActivePaseoBrowserId,
@@ -328,6 +329,12 @@ ipcMain.handle("paseo:browser:register-workspace-browser", (_event, rawInput: un
}
});
ipcMain.handle("paseo:browser:unregister-workspace-browser", (_event, browserId: unknown) => {
if (typeof browserId === "string" && browserId.trim().length > 0) {
unregisterPaseoBrowser(browserId.trim());
}
});
ipcMain.handle("paseo:browser:set-workspace-active-browser", (_event, rawInput: unknown) => {
const input = readActiveBrowserInput(rawInput);
if (input) {
+2
View File
@@ -76,6 +76,8 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
browser: {
registerWorkspaceBrowser: (input: { browserId: string; workspaceId: string }) =>
ipcRenderer.invoke("paseo:browser:register-workspace-browser", input),
unregisterWorkspaceBrowser: (browserId: string) =>
ipcRenderer.invoke("paseo:browser:unregister-workspace-browser", browserId),
setWorkspaceActiveBrowser: (input: { workspaceId: string; browserId: string | null }) =>
ipcRenderer.invoke("paseo:browser:set-workspace-active-browser", input),
openDevTools: (browserId: string) =>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.104-beta.2",
"version": "0.1.104-beta.4",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.104-beta.2",
"version": "0.1.104-beta.4",
"files": [
"dist",
"!dist/**/*.map"
+7
View File
@@ -0,0 +1,7 @@
# Protocol Validator Codegen
This directory is build-time only. `ws-outbound.compile.ts` is the zod-aot discovery entry for the inbound WebSocket validator.
The generated runtime file is written to `../src/generated/validation/ws-outbound.aot.ts` and is not committed. The protocol package owns every generation trigger through its npm lifecycle scripts.
`zod-aot` is exact-pinned, and the protocol generator applies the small compiler patches it requires before generation. Treat changes to those patches like compiler changes: regenerate, inspect the output, and run the protocol validation regression tests before shipping.
@@ -0,0 +1,4 @@
import { compile } from "zod-aot";
import { WSOutboundMessageSchema as SourceWSOutboundMessageSchema } from "../src/messages.js";
export const WSOutboundMessageSchema = compile(SourceWSOutboundMessageSchema);
+9 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.1.104-beta.2",
"version": "0.1.104-beta.4",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
@@ -23,7 +23,12 @@
"build:clean": "npm run clean && npm run build",
"prepack": "npm run build:clean",
"typecheck": "tsgo --noEmit",
"test": "vitest run"
"test": "vitest run",
"generate:validators": "node scripts/generate-validation-aot.mjs",
"watch": "concurrently --kill-others --names validation,tsc --prefix-colors green,yellow \"node scripts/watch-validation-aot.mjs\" \"tsc -p tsconfig.json --watch --preserveWatchOutput\"",
"prebuild": "npm run generate:validators",
"pretypecheck": "npm run generate:validators",
"pretest": "npm run generate:validators"
},
"dependencies": {
"zod": "^4.4.3"
@@ -31,6 +36,7 @@
"devDependencies": {
"@types/node": "^20.9.0",
"typescript": "^5.2.2",
"vitest": "^4.1.6"
"vitest": "^4.1.6",
"zod-aot": "0.20.4"
}
}
@@ -0,0 +1,106 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { dirname, relative, resolve, sep } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const source = resolve(packageRoot, "codegen/ws-outbound.compile.ts");
const runtimeSchemaMetadata = resolve(packageRoot, "src/validation/ws-outbound-schema-metadata.ts");
const output = resolve(packageRoot, "src/generated/validation/ws-outbound.aot.ts");
const require = createRequire(import.meta.url);
const zodAotEntry = require.resolve("zod-aot");
const zodAotRoot = resolve(dirname(zodAotEntry), "..");
const emitterPath = resolve(zodAotRoot, "dist/cli/emitter.js");
const discriminatedUnionPath = resolve(
zodAotRoot,
"dist/core/codegen/schemas/discriminated-union.js",
);
async function ensureZodAotRuntimeImportExtensionPatch() {
const emitter = await readFile(emitterPath, "utf8");
if (emitter.includes('sourceRelPath.endsWith(".js")')) {
return;
}
const before = 'let importPath = sourceRelPath.replace(/\\.[cm]?[jt]sx?$/, "");';
const after =
'let importPath = sourceRelPath.endsWith(".js")\n ? sourceRelPath\n : sourceRelPath.replace(/\\.[cm]?[jt]sx?$/, "");';
if (!emitter.includes(before)) {
throw new Error("zod-aot emitter shape changed; update the runtime import extension patch");
}
await writeFile(emitterPath, emitter.replace(before, after));
}
async function ensureZodAotDiscriminatedUnionOutputPatch() {
let discriminatedUnionEmitter = await readFile(discriminatedUnionPath, "utf8");
if (
discriminatedUnionEmitter.includes(
"const needsOutputPropagation = ir.options.some(hasMutation);",
)
) {
return;
}
const importBefore = 'import { escapeString } from "../context.js";';
const importAfter = 'import { escapeString, hasMutation } from "../context.js";';
const outputFlagBefore = "const discKey = escapeString(ir.discriminator);\n let code = emit `";
const outputFlagAfter =
"const discKey = escapeString(ir.discriminator);\n const needsOutputPropagation = ir.options.some(hasMutation);\n let code = emit `";
const propagationBefore =
" ${g.visit(option, { input: objVar, output: objVar })}\n break;`;";
const propagationAfter =
' ${g.visit(option, { input: objVar, output: objVar })}\n ${needsOutputPropagation ? `${g.output}=${objVar};` : ""}\n break;`;';
if (
!discriminatedUnionEmitter.includes(importBefore) ||
!discriminatedUnionEmitter.includes(outputFlagBefore) ||
!discriminatedUnionEmitter.includes(propagationBefore)
) {
throw new Error("zod-aot discriminated-union emitter shape changed; update the output patch");
}
discriminatedUnionEmitter = discriminatedUnionEmitter
.replace(importBefore, importAfter)
.replace(outputFlagBefore, outputFlagAfter)
.replace(propagationBefore, propagationAfter);
await writeFile(discriminatedUnionPath, discriminatedUnionEmitter);
}
await Promise.all([
ensureZodAotRuntimeImportExtensionPatch(),
ensureZodAotDiscriminatedUnionOutputPatch(),
]);
const [{ discoverSchemas }, { compileSchemas }, { generateCompiledFileContent }] =
await Promise.all([
import(pathToFileURL(resolve(zodAotRoot, "dist/discovery.js")).href),
import(pathToFileURL(resolve(zodAotRoot, "dist/core/pipeline.js")).href),
import(pathToFileURL(resolve(zodAotRoot, "dist/cli/emitter.js")).href),
]);
const schemas = await discoverSchemas(source, { cacheBust: true });
if (schemas.length === 0) {
throw new Error(`No zod-aot compile() exports found in ${relative(packageRoot, source)}`);
}
const compiled = compileSchemas(schemas, { mode: "inline" });
const runtimeImportPath = relative(dirname(output), runtimeSchemaMetadata)
.replace(/\.[cm]?[jt]sx?$/, ".js")
.split(sep)
.join("/");
const content = generateCompiledFileContent(compiled, runtimeImportPath, {
zodCompat: false,
}).replace(
"// AUTO-GENERATED by zod-aot — DO NOT EDIT",
"// @ts-nocheck\n// AUTO-GENERATED by zod-aot — DO NOT EDIT",
);
await mkdir(dirname(output), { recursive: true });
await writeFile(output, content);
console.info(
`generated ${relative(packageRoot, output)} from ${relative(packageRoot, source)} (${schemas
.map((schema) => schema.exportName)
.join(", ")})`,
);
@@ -0,0 +1,96 @@
import { readdir, stat } from "node:fs/promises";
import { dirname, join, relative, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { spawn } from "node:child_process";
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const protocolSrcDir = join(packageRoot, "src");
const pollIntervalMs = 1000;
let currentFingerprint = "";
let generateInFlight = false;
let generateAgain = false;
async function collectSourceFiles(dir) {
const entries = await readdir(dir, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const path = join(dir, entry.name);
const relativePath = relative(protocolSrcDir, path);
const parts = relativePath.split(sep);
if (parts[0] === "generated") {
continue;
}
if (entry.isDirectory()) {
files.push(...(await collectSourceFiles(path)));
continue;
}
if (entry.isFile() && entry.name.endsWith(".ts")) {
files.push(path);
}
}
return files;
}
async function fingerprintSources() {
const files = await collectSourceFiles(protocolSrcDir);
const stats = await Promise.all(
files.sort().map(async (file) => {
const fileStat = await stat(file);
return `${relative(packageRoot, file)}:${fileStat.mtimeMs}:${fileStat.size}`;
}),
);
return stats.join("\n");
}
function runGenerate() {
if (generateInFlight) {
generateAgain = true;
return;
}
generateInFlight = true;
const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm";
const child = spawn(npmCommand, ["run", "generate:validators"], {
cwd: packageRoot,
stdio: "inherit",
});
child.on("exit", (code, signal) => {
generateInFlight = false;
if (code !== 0) {
console.error(
signal
? `validation AOT generation stopped by ${signal}`
: `validation AOT generation failed with exit code ${code}`,
);
}
if (generateAgain) {
generateAgain = false;
runGenerate();
}
});
}
async function poll() {
try {
const nextFingerprint = await fingerprintSources();
if (currentFingerprint && nextFingerprint !== currentFingerprint) {
runGenerate();
}
currentFingerprint = nextFingerprint;
} catch (error) {
console.error("validation AOT watcher failed to scan protocol sources", error);
}
}
currentFingerprint = await fingerprintSources();
console.log("Watching protocol schema sources for validation AOT changes...");
setInterval(poll, pollIntervalMs);
@@ -0,0 +1,41 @@
import { z } from "zod";
import {
BROWSER_AUTOMATION_COMMAND_NAMES,
type BrowserAutomationCommandName,
} from "./rpc-schemas.js";
const KNOWN_BROWSER_AUTOMATION_COMMAND_NAMES = new Set<string>(BROWSER_AUTOMATION_COMMAND_NAMES);
export const BrowserAutomationHostCapabilitySchema = z
.object({
supportedCommands: z.array(z.string().min(1)).transform((commands, context) => {
const supportedCommands: BrowserAutomationCommandName[] = [];
const seen = new Set<BrowserAutomationCommandName>();
for (const command of commands) {
if (!isKnownBrowserAutomationCommandName(command) || seen.has(command)) {
continue;
}
seen.add(command);
supportedCommands.push(command);
}
if (supportedCommands.length === 0) {
context.addIssue({
code: "custom",
message: "supportedCommands must include at least one known browser automation command",
});
return z.NEVER;
}
return supportedCommands;
}),
hostKind: z.string().min(1).default("browser host"),
})
.passthrough();
export type BrowserAutomationHostCapability = z.infer<typeof BrowserAutomationHostCapabilitySchema>;
function isKnownBrowserAutomationCommandName(value: string): value is BrowserAutomationCommandName {
return KNOWN_BROWSER_AUTOMATION_COMMAND_NAMES.has(value);
}
@@ -15,7 +15,39 @@ const commandParseCases = [
{
name: "click",
command: { command: "click", args: { browserId: BROWSER_ID, ref: "@e1" } },
expected: { command: "click", args: { browserId: BROWSER_ID, ref: "@e1" } },
expected: {
command: "click",
args: {
browserId: BROWSER_ID,
ref: "@e1",
button: "left",
doubleClick: false,
modifiers: [],
},
},
},
{
name: "click options",
command: {
command: "click",
args: {
browserId: BROWSER_ID,
ref: "@e1",
button: "right",
doubleClick: true,
modifiers: ["Meta", "Shift"],
},
},
expected: {
command: "click",
args: {
browserId: BROWSER_ID,
ref: "@e1",
button: "right",
doubleClick: true,
modifiers: ["Meta", "Shift"],
},
},
},
{
name: "fill",
@@ -123,6 +155,72 @@ const commandParseCases = [
command: { command: "logs", args: { browserId: BROWSER_ID } },
expected: { command: "logs", args: { browserId: BROWSER_ID, maxEntries: 50 } },
},
{
name: "evaluate",
command: {
command: "evaluate",
args: { browserId: BROWSER_ID, function: "() => document.title" },
},
expected: {
command: "evaluate",
args: { browserId: BROWSER_ID, function: "() => document.title" },
},
},
{
name: "evaluate with ref",
command: {
command: "evaluate",
args: { browserId: BROWSER_ID, function: "(element) => element.textContent", ref: "@e1" },
},
expected: {
command: "evaluate",
args: { browserId: BROWSER_ID, function: "(element) => element.textContent", ref: "@e1" },
},
},
{
name: "scroll",
command: {
command: "scroll",
args: { browserId: BROWSER_ID, deltaX: 0, deltaY: 400 },
},
expected: {
command: "scroll",
args: { browserId: BROWSER_ID, deltaX: 0, deltaY: 400 },
},
},
{
name: "scroll with ref",
command: {
command: "scroll",
args: { browserId: BROWSER_ID, ref: "@e1", deltaX: 10, deltaY: -20 },
},
expected: {
command: "scroll",
args: { browserId: BROWSER_ID, ref: "@e1", deltaX: 10, deltaY: -20 },
},
},
{
name: "resize",
command: {
command: "resize",
args: { browserId: BROWSER_ID, width: 1024, height: 768 },
},
expected: {
command: "resize",
args: { browserId: BROWSER_ID, width: 1024, height: 768 },
},
},
{
name: "close_tab",
command: {
command: "close_tab",
args: { browserId: BROWSER_ID },
},
expected: {
command: "close_tab",
args: { browserId: BROWSER_ID },
},
},
] as const;
const resultParseCases = [
@@ -134,16 +232,10 @@ const resultParseCases = [
workspaceId: "workspace-1",
url: "https://example.com/form",
title: "Fixture",
elements: [
{
ref: "@e1",
role: "textbox",
tagName: "input",
text: "Name",
selector: "#name",
attributes: { id: "name", type: "text" },
},
],
format: "aria-yaml",
snapshot: '- document "Fixture"\n - textbox "Name" [ref=@e1]',
truncated: false,
stats: { nodeCount: 2, refCount: 1, textLength: 52, iframeCount: 0, maxDepth: 1 },
},
expected: {
command: "snapshot",
@@ -151,16 +243,10 @@ const resultParseCases = [
workspaceId: "workspace-1",
url: "https://example.com/form",
title: "Fixture",
elements: [
{
ref: "@e1",
role: "textbox",
tagName: "input",
text: "Name",
selector: "#name",
attributes: { id: "name", type: "text" },
},
],
format: "aria-yaml",
snapshot: '- document "Fixture"\n - textbox "Name" [ref=@e1]',
truncated: false,
stats: { nodeCount: 2, refCount: 1, textLength: 52, iframeCount: 0, maxDepth: 1 },
},
},
{
@@ -305,6 +391,68 @@ const resultParseCases = [
],
},
},
{
name: "evaluate",
result: {
command: "evaluate",
browserId: BROWSER_ID,
resultJson: '{"title":"Fixture"}',
truncated: false,
},
expected: {
command: "evaluate",
browserId: BROWSER_ID,
resultJson: '{"title":"Fixture"}',
truncated: false,
},
},
{
name: "scroll",
result: {
command: "scroll",
browserId: BROWSER_ID,
ref: "@e1",
deltaX: 10,
deltaY: 400,
x: 40,
y: 30,
},
expected: {
command: "scroll",
browserId: BROWSER_ID,
ref: "@e1",
deltaX: 10,
deltaY: 400,
x: 40,
y: 30,
},
},
{
name: "resize",
result: {
command: "resize",
browserId: BROWSER_ID,
width: 1024,
height: 768,
},
expected: {
command: "resize",
browserId: BROWSER_ID,
width: 1024,
height: 768,
},
},
{
name: "close_tab",
result: {
command: "close_tab",
browserId: BROWSER_ID,
},
expected: {
command: "close_tab",
browserId: BROWSER_ID,
},
},
] as const;
describe("browser automation execute RPC schemas", () => {
@@ -526,7 +674,10 @@ describe("browser automation execute RPC schemas", () => {
workspaceId: "workspace-1",
url: "https://example.com",
title: "Example",
elements: [],
format: "aria-yaml",
snapshot: "- document",
truncated: false,
stats: { nodeCount: 1, refCount: 0, textLength: 10 },
},
},
});
@@ -557,6 +708,53 @@ describe("browser automation execute RPC schemas", () => {
},
);
test("success responses can report handled dialogs", () => {
const parsed = BrowserAutomationExecuteResponseSchema.parse({
type: "browser.automation.execute.response",
payload: {
requestId: "req-click",
ok: true,
result: { command: "click", browserId: BROWSER_ID, ref: "@e1" },
dialogs: [
{
type: "alert",
message: "Saved",
action: "accepted",
timestamp: 123,
},
{
type: "prompt",
message: "Name?",
defaultValue: "Maya",
action: "dismissed",
timestamp: 124,
},
],
},
});
expect(parsed.payload).toEqual({
requestId: "req-click",
ok: true,
result: { command: "click", browserId: BROWSER_ID, ref: "@e1" },
dialogs: [
{
type: "alert",
message: "Saved",
action: "accepted",
timestamp: 123,
},
{
type: "prompt",
message: "Name?",
defaultValue: "Maya",
action: "dismissed",
timestamp: 124,
},
],
});
});
test("error responses keep stable codes, messages, and retry defaults", () => {
const parsed = BrowserAutomationExecuteResponseSchema.parse({
type: "browser.automation.execute.response",
@@ -564,8 +762,8 @@ describe("browser automation execute RPC schemas", () => {
requestId: "req-error",
ok: false,
error: {
code: "browser_no_desktop",
message: "No desktop browser automation client is connected.",
code: "browser_no_host",
message: "No browser automation host is connected.",
},
},
});
@@ -574,10 +772,50 @@ describe("browser automation execute RPC schemas", () => {
requestId: "req-error",
ok: false,
error: {
code: "browser_no_desktop",
message: "No desktop browser automation client is connected.",
code: "browser_no_host",
message: "No browser automation host is connected.",
retryable: false,
},
});
});
test("failure responses can report handled dialogs", () => {
const parsed = BrowserAutomationExecuteResponseSchema.parse({
type: "browser.automation.execute.response",
payload: {
requestId: "req-navigate",
ok: false,
error: {
code: "browser_timeout",
message: "Timed out waiting for browser URL: /next",
},
dialogs: [
{
type: "beforeunload",
message: "Leave site?",
action: "dismissed",
timestamp: 200,
},
],
},
});
expect(parsed.payload).toEqual({
requestId: "req-navigate",
ok: false,
error: {
code: "browser_timeout",
message: "Timed out waiting for browser URL: /next",
retryable: false,
},
dialogs: [
{
type: "beforeunload",
message: "Leave site?",
action: "dismissed",
timestamp: 200,
},
],
});
});
});
@@ -2,7 +2,7 @@ import { z } from "zod";
export const BrowserAutomationErrorCodeSchema = z.enum([
"browser_disabled",
"browser_no_desktop",
"browser_no_host",
"browser_tab_not_found",
"browser_tab_closed",
"browser_timeout",
@@ -20,6 +20,33 @@ const BROWSER_AUTOMATION_BROWSER_ID_MESSAGE =
const BROWSER_AUTOMATION_WAIT_CONDITION_MESSAGE =
"browser_wait requires exactly one of text or url";
export const BROWSER_AUTOMATION_COMMAND_NAMES = [
"list_tabs",
"new_tab",
"snapshot",
"click",
"fill",
"wait",
"type",
"keypress",
"navigate",
"back",
"forward",
"reload",
"screenshot",
"upload",
"select",
"hover",
"drag",
"logs",
"evaluate",
"scroll",
"resize",
"close_tab",
] as const;
export const BrowserAutomationCommandNameSchema = z.enum(BROWSER_AUTOMATION_COMMAND_NAMES);
export const BrowserAutomationBrowserIdSchema = z
.string({ error: () => BROWSER_AUTOMATION_BROWSER_ID_MESSAGE })
.min(1, BROWSER_AUTOMATION_BROWSER_ID_MESSAGE)
@@ -32,6 +59,8 @@ const BrowserAutomationTabTargetSchema = z
.strict();
const BrowserAutomationRefSchema = z.string().regex(/^@e\d+$/);
const BrowserAutomationMouseButtonSchema = z.enum(["left", "right", "middle"]);
const BrowserAutomationInputModifierSchema = z.enum(["Alt", "Control", "Meta", "Shift"]);
const BrowserAutomationHttpUrlSchema = z
.string()
.url()
@@ -64,6 +93,9 @@ export const BrowserAutomationClickCommandSchema = z.object({
command: z.literal("click"),
args: BrowserAutomationTabTargetSchema.extend({
ref: BrowserAutomationRefSchema,
button: BrowserAutomationMouseButtonSchema.default("left"),
doubleClick: z.boolean().default(false),
modifiers: z.array(BrowserAutomationInputModifierSchema).default([]),
}),
});
@@ -169,6 +201,36 @@ export const BrowserAutomationLogsCommandSchema = z.object({
}),
});
export const BrowserAutomationEvaluateCommandSchema = z.object({
command: z.literal("evaluate"),
args: BrowserAutomationTabTargetSchema.extend({
function: z.string().min(1),
ref: BrowserAutomationRefSchema.optional(),
}),
});
export const BrowserAutomationScrollCommandSchema = z.object({
command: z.literal("scroll"),
args: BrowserAutomationTabTargetSchema.extend({
ref: BrowserAutomationRefSchema.optional(),
deltaX: z.number(),
deltaY: z.number(),
}),
});
export const BrowserAutomationResizeCommandSchema = z.object({
command: z.literal("resize"),
args: BrowserAutomationTabTargetSchema.extend({
width: z.number().int().positive(),
height: z.number().int().positive(),
}),
});
export const BrowserAutomationCloseTabCommandSchema = z.object({
command: z.literal("close_tab"),
args: BrowserAutomationTabTargetSchema,
});
export const BrowserAutomationCommandSchema = z.discriminatedUnion("command", [
BrowserAutomationListTabsCommandSchema,
BrowserAutomationNewTabCommandSchema,
@@ -188,6 +250,10 @@ export const BrowserAutomationCommandSchema = z.discriminatedUnion("command", [
BrowserAutomationHoverCommandSchema,
BrowserAutomationDragCommandSchema,
BrowserAutomationLogsCommandSchema,
BrowserAutomationEvaluateCommandSchema,
BrowserAutomationScrollCommandSchema,
BrowserAutomationResizeCommandSchema,
BrowserAutomationCloseTabCommandSchema,
]);
export const BrowserAutomationTabInfoSchema = z.object({
@@ -213,14 +279,15 @@ export const BrowserAutomationNewTabResultSchema = z.object({
url: z.string().min(1),
});
export const BrowserAutomationSnapshotElementSchema = z.object({
ref: z.string().regex(/^@e\d+$/),
role: z.string(),
tagName: z.string(),
text: z.string(),
selector: z.string(),
attributes: z.record(z.string(), z.string()).default({}),
});
export const BrowserAutomationSnapshotStatsSchema = z
.object({
nodeCount: z.number().int().nonnegative(),
refCount: z.number().int().nonnegative(),
textLength: z.number().int().nonnegative(),
iframeCount: z.number().int().nonnegative().optional(),
maxDepth: z.number().int().nonnegative().optional(),
})
.strict();
export const BrowserAutomationSnapshotResultSchema = z.object({
command: z.literal("snapshot"),
@@ -228,13 +295,18 @@ export const BrowserAutomationSnapshotResultSchema = z.object({
workspaceId: z.string().min(1).optional(),
url: z.string(),
title: z.string(),
elements: z.array(BrowserAutomationSnapshotElementSchema),
format: z.literal("aria-yaml"),
snapshot: z.string(),
truncated: z.boolean(),
stats: BrowserAutomationSnapshotStatsSchema,
});
export const BrowserAutomationClickResultSchema = z.object({
command: z.literal("click"),
browserId: BrowserAutomationBrowserIdSchema,
ref: BrowserAutomationRefSchema,
x: z.number().optional(),
y: z.number().optional(),
});
export const BrowserAutomationFillResultSchema = z.object({
@@ -253,6 +325,8 @@ export const BrowserAutomationTypeResultSchema = z.object({
command: z.literal("type"),
browserId: BrowserAutomationBrowserIdSchema,
ref: BrowserAutomationRefSchema.optional(),
x: z.number().optional(),
y: z.number().optional(),
});
export const BrowserAutomationKeypressResultSchema = z.object({
@@ -260,6 +334,8 @@ export const BrowserAutomationKeypressResultSchema = z.object({
browserId: BrowserAutomationBrowserIdSchema,
key: z.string().min(1),
ref: BrowserAutomationRefSchema.optional(),
x: z.number().optional(),
y: z.number().optional(),
});
export const BrowserAutomationNavigateResultSchema = z.object({
@@ -310,6 +386,8 @@ export const BrowserAutomationHoverResultSchema = z.object({
command: z.literal("hover"),
browserId: BrowserAutomationBrowserIdSchema,
ref: BrowserAutomationRefSchema,
x: z.number().optional(),
y: z.number().optional(),
});
export const BrowserAutomationDragResultSchema = z.object({
@@ -317,6 +395,10 @@ export const BrowserAutomationDragResultSchema = z.object({
browserId: BrowserAutomationBrowserIdSchema,
sourceRef: BrowserAutomationRefSchema,
targetRef: BrowserAutomationRefSchema,
sourceX: z.number().optional(),
sourceY: z.number().optional(),
targetX: z.number().optional(),
targetY: z.number().optional(),
});
export const BrowserAutomationConsoleLogEntrySchema = z.object({
@@ -344,6 +426,35 @@ export const BrowserAutomationLogsResultSchema = z.object({
network: z.array(BrowserAutomationNetworkLogEntrySchema),
});
export const BrowserAutomationEvaluateResultSchema = z.object({
command: z.literal("evaluate"),
browserId: BrowserAutomationBrowserIdSchema,
resultJson: z.string(),
truncated: z.boolean(),
});
export const BrowserAutomationScrollResultSchema = z.object({
command: z.literal("scroll"),
browserId: BrowserAutomationBrowserIdSchema,
ref: BrowserAutomationRefSchema.optional(),
deltaX: z.number(),
deltaY: z.number(),
x: z.number().optional(),
y: z.number().optional(),
});
export const BrowserAutomationResizeResultSchema = z.object({
command: z.literal("resize"),
browserId: BrowserAutomationBrowserIdSchema,
width: z.number().int().positive(),
height: z.number().int().positive(),
});
export const BrowserAutomationCloseTabResultSchema = z.object({
command: z.literal("close_tab"),
browserId: BrowserAutomationBrowserIdSchema,
});
export const BrowserAutomationResultSchema = z.discriminatedUnion("command", [
BrowserAutomationListTabsResultSchema,
BrowserAutomationNewTabResultSchema,
@@ -363,6 +474,10 @@ export const BrowserAutomationResultSchema = z.discriminatedUnion("command", [
BrowserAutomationHoverResultSchema,
BrowserAutomationDragResultSchema,
BrowserAutomationLogsResultSchema,
BrowserAutomationEvaluateResultSchema,
BrowserAutomationScrollResultSchema,
BrowserAutomationResizeResultSchema,
BrowserAutomationCloseTabResultSchema,
]);
export const BrowserAutomationErrorSchema = z.object({
@@ -371,6 +486,15 @@ export const BrowserAutomationErrorSchema = z.object({
retryable: z.boolean().default(false),
});
export const BrowserAutomationDialogEventSchema = z.object({
type: z.enum(["alert", "confirm", "prompt", "beforeunload"]),
message: z.string(),
defaultValue: z.string().optional(),
action: z.enum(["accepted", "dismissed"]),
promptText: z.string().optional(),
timestamp: z.number(),
});
export const BrowserAutomationExecuteRequestSchema = z
.object({
type: z.literal("browser.automation.execute.request"),
@@ -389,16 +513,19 @@ export const BrowserAutomationExecuteResponseSchema = z.object({
requestId: z.string().min(1),
ok: z.literal(true),
result: BrowserAutomationResultSchema,
dialogs: z.array(BrowserAutomationDialogEventSchema).optional(),
}),
z.object({
requestId: z.string().min(1),
ok: z.literal(false),
error: BrowserAutomationErrorSchema,
dialogs: z.array(BrowserAutomationDialogEventSchema).optional(),
}),
]),
});
export type BrowserAutomationErrorCode = z.infer<typeof BrowserAutomationErrorCodeSchema>;
export type BrowserAutomationCommandName = z.infer<typeof BrowserAutomationCommandNameSchema>;
export type BrowserAutomationCommand = z.infer<typeof BrowserAutomationCommandSchema>;
export type BrowserAutomationResult = z.infer<typeof BrowserAutomationResultSchema>;
export type BrowserAutomationConsoleLogEntry = z.infer<
@@ -407,6 +534,7 @@ export type BrowserAutomationConsoleLogEntry = z.infer<
export type BrowserAutomationNetworkLogEntry = z.infer<
typeof BrowserAutomationNetworkLogEntrySchema
>;
export type BrowserAutomationDialogEvent = z.infer<typeof BrowserAutomationDialogEventSchema>;
export type BrowserAutomationExecuteRequest = z.infer<typeof BrowserAutomationExecuteRequestSchema>;
export type BrowserAutomationExecuteResponse = z.infer<
typeof BrowserAutomationExecuteResponseSchema
+1 -1
View File
@@ -11,7 +11,7 @@ export const CLIENT_CAPS = {
// Old clients use a strict TerminalState schema and would reject the extra fields.
// Drop the gate (always send the flags) when floor >= v0.1.88.
terminalReflowableSnapshot: "terminal_reflowable_snapshot",
desktopBrowserAutomation: "desktop_browser_automation",
browserHost: "browser_host",
} as const;
export type ClientCapability = (typeof CLIENT_CAPS)[keyof typeof CLIENT_CAPS];
@@ -0,0 +1,11 @@
# Generated protocol validators
`ws-outbound.aot.ts` is generated by zod-aot from `packages/protocol/codegen/ws-outbound.compile.ts`.
Run:
```bash
npm run generate:validators --workspace=@getpaseo/protocol
```
The generated TypeScript is intentionally gitignored. zod-aot is exact-pinned because this is a young solo-maintainer project; keep the generated validator behind protocol regression tests when upgrading it.
+1 -1
View File
@@ -1,5 +1,5 @@
import { z } from "zod";
import { AgentProviderSchema } from "@getpaseo/protocol/provider-manifest";
import { AgentProviderSchema } from "../provider-manifest.js";
export const LoopLogEntrySchema = z.object({
seq: z.number().int().positive(),
@@ -1,5 +1,6 @@
import { describe, expect, test } from "vitest";
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "./browser-automation/rpc-schemas.js";
import { CLIENT_CAPS } from "./client-capabilities.js";
import {
MutableDaemonConfigPatchSchema,
@@ -12,7 +13,7 @@ import {
describe("browser automation protocol integration", () => {
const browserId = "11111111-1111-4111-8111-111111111111";
test("desktop automation capability parses in hello without narrowing old clients", () => {
test("browser host capability parses supported commands in hello", () => {
expect(
WSHelloMessageSchema.parse({
type: "hello",
@@ -20,13 +21,108 @@ describe("browser automation protocol integration", () => {
clientType: "mobile",
protocolVersion: 1,
capabilities: {
[CLIENT_CAPS.desktopBrowserAutomation]: true,
[CLIENT_CAPS.browserHost]: {
supportedCommands: [...BROWSER_AUTOMATION_COMMAND_NAMES],
hostKind: "desktop app",
},
},
}).capabilities,
).toMatchObject({
[CLIENT_CAPS.desktopBrowserAutomation]: true,
[CLIENT_CAPS.browserHost]: {
supportedCommands: [...BROWSER_AUTOMATION_COMMAND_NAMES],
hostKind: "desktop app",
},
});
});
test("browser host capability requires at least one supported command", () => {
expect(() =>
WSHelloMessageSchema.parse({
type: "hello",
clientId: "client-1",
clientType: "mobile",
protocolVersion: 1,
capabilities: {
[CLIENT_CAPS.browserHost]: {
supportedCommands: [],
hostKind: "desktop app",
},
},
}),
).toThrow();
expect(() =>
WSHelloMessageSchema.parse({
type: "hello",
clientId: "client-2",
clientType: "mobile",
protocolVersion: 1,
capabilities: {
[CLIENT_CAPS.browserHost]: {},
},
}),
).toThrow();
expect(() =>
WSHelloMessageSchema.parse({
type: "hello",
clientId: "client-3",
clientType: "mobile",
protocolVersion: 1,
capabilities: {
[CLIENT_CAPS.browserHost]: {
supportedCommands: ["future_command"],
},
},
}),
).toThrow();
});
test("browser host capability ignores unknown future commands when known commands remain", () => {
expect(
WSHelloMessageSchema.parse({
type: "hello",
clientId: "client-1",
clientType: "mobile",
protocolVersion: 1,
capabilities: {
[CLIENT_CAPS.browserHost]: {
supportedCommands: ["list_tabs", "future_command", "list_tabs"],
hostKind: "desktop app",
},
},
}).capabilities,
).toMatchObject({
[CLIENT_CAPS.browserHost]: {
supportedCommands: ["list_tabs"],
hostKind: "desktop app",
},
});
});
test("browser host capability accepts new tool commands as supported commands", () => {
expect(
WSHelloMessageSchema.parse({
type: "hello",
clientId: "client-1",
clientType: "mobile",
protocolVersion: 1,
capabilities: {
[CLIENT_CAPS.browserHost]: {
supportedCommands: ["evaluate", "scroll", "resize", "close_tab"],
hostKind: "desktop app",
},
},
}).capabilities,
).toMatchObject({
[CLIENT_CAPS.browserHost]: {
supportedCommands: ["evaluate", "scroll", "resize", "close_tab"],
hostKind: "desktop app",
},
});
});
test("hello remains valid when no browser host capability is advertised", () => {
expect(
WSHelloMessageSchema.parse({
type: "hello",
@@ -37,7 +133,7 @@ describe("browser automation protocol integration", () => {
).toBeUndefined();
});
test("daemon to desktop execute request is an outbound session message", () => {
test("daemon to browser host execute request is an outbound session message", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "browser.automation.execute.request",
requestId: "req-1",
@@ -47,7 +143,7 @@ describe("browser automation protocol integration", () => {
expect(parsed.type).toBe("browser.automation.execute.request");
});
test("desktop to daemon execute response is an inbound session message", () => {
test("browser host to daemon execute response is an inbound session message", () => {
const parsed = SessionInboundMessageSchema.parse({
type: "browser.automation.execute.response",
payload: {
@@ -38,41 +38,6 @@ describe("provider snapshot message schemas", () => {
expect(parsed.enabled).toBe(true);
});
test("normalizes thinking option defaults on provider snapshot models", () => {
const parsed = ProviderSnapshotEntrySchema.parse({
provider: "claude",
status: "ready",
models: [
{
provider: "claude",
id: "MiniMax-M2.7",
label: "MiniMax-M2.7",
isDefault: true,
contextWindowMaxTokens: 1_000_000,
thinkingOptions: [
{ id: "off", label: "Off" },
{ id: "max", label: "Max", isDefault: true },
],
},
],
});
expect(parsed.models).toEqual([
{
provider: "claude",
id: "MiniMax-M2.7",
label: "MiniMax-M2.7",
isDefault: true,
contextWindowMaxTokens: 1_000_000,
thinkingOptions: [
{ id: "off", label: "Off" },
{ id: "max", label: "Max", isDefault: true },
],
defaultThinkingOptionId: "max",
},
]);
});
test("defaults missing enabled state in providers snapshot response entries", () => {
const parsed = GetProvidersSnapshotResponseMessageSchema.parse({
type: "get_providers_snapshot_response",
+49 -42
View File
@@ -2,9 +2,9 @@ import { z } from "zod";
import { TerminalActivitySchema } from "./terminal-activity.js";
import { CLIENT_CAPS } from "./client-capabilities.js";
import { AGENT_LIFECYCLE_STATUSES } from "./agent-lifecycle.js";
import { MAX_EXPLICIT_AGENT_TITLE_CHARS } from "@getpaseo/protocol/agent-title-limits";
import { AgentProviderSchema } from "@getpaseo/protocol/provider-manifest";
import { normalizeAgentModelDefinition, TOOL_CALL_ICON_NAMES } from "./agent-types.js";
import { MAX_EXPLICIT_AGENT_TITLE_CHARS } from "./agent-title-limits.js";
import { AgentProviderSchema } from "./provider-manifest.js";
import { TOOL_CALL_ICON_NAMES } from "./agent-types.js";
import {
ChatCreateRequestSchema,
ChatListRequestSchema,
@@ -40,7 +40,7 @@ import {
ScheduleDeleteResponseSchema,
ScheduleRunOnceResponseSchema,
ScheduleUpdateResponseSchema,
} from "@getpaseo/protocol/schedule/rpc-schemas";
} from "./schedule/rpc-schemas.js";
import {
LoopRunRequestSchema,
LoopListRequestSchema,
@@ -52,11 +52,12 @@ import {
LoopInspectResponseSchema,
LoopLogsResponseSchema,
LoopStopResponseSchema,
} from "@getpaseo/protocol/loop/rpc-schemas";
} from "./loop/rpc-schemas.js";
import {
BrowserAutomationExecuteRequestSchema,
BrowserAutomationExecuteResponseSchema,
} from "./browser-automation/rpc-schemas.js";
import { BrowserAutomationHostCapabilitySchema } from "./browser-automation/capabilities.js";
import {
PaseoConfigRawSchema,
PaseoLifecycleCommandRawSchema,
@@ -72,7 +73,7 @@ import {
type PaseoMetadataGenerationEntry,
type PaseoScriptEntryRaw,
type ProjectConfigRpcError,
} from "@getpaseo/protocol/paseo-config-schema";
} from "./paseo-config-schema.js";
export {
PaseoConfigRawSchema,
PaseoLifecycleCommandRawSchema,
@@ -246,19 +247,17 @@ export const AgentFeatureSchema = z.discriminatedUnion("type", [
AgentFeatureSelectSchema,
]);
const AgentModelDefinitionSchema: z.ZodType<AgentModelDefinition> = z
.object({
provider: AgentProviderSchema,
id: z.string(),
label: z.string(),
description: z.string().optional(),
isDefault: z.boolean().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
contextWindowMaxTokens: z.number().optional(),
thinkingOptions: z.array(AgentSelectOptionSchema).optional(),
defaultThinkingOptionId: z.string().optional(),
})
.transform(normalizeAgentModelDefinition);
const AgentModelDefinitionSchema: z.ZodType<AgentModelDefinition> = z.object({
provider: AgentProviderSchema,
id: z.string(),
label: z.string(),
description: z.string().optional(),
isDefault: z.boolean().optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
contextWindowMaxTokens: z.number().optional(),
thinkingOptions: z.array(AgentSelectOptionSchema).optional(),
defaultThinkingOptionId: z.string().optional(),
});
export const ProviderSnapshotEntrySchema = z.object({
provider: AgentProviderSchema,
@@ -360,20 +359,21 @@ const AgentPermissionActionSchema = z.object({
intent: z.enum(["implement", "implement_resume", "dismiss"]).optional(),
});
export const AgentPermissionResponseSchema: z.ZodType<AgentPermissionResponse> = z.union([
z.object({
behavior: z.literal("allow"),
selectedActionId: z.string().optional(),
updatedInput: z.record(z.string(), z.unknown()).optional(),
updatedPermissions: z.array(AgentPermissionUpdateSchema).optional(),
}),
z.object({
behavior: z.literal("deny"),
selectedActionId: z.string().optional(),
message: z.string().optional(),
interrupt: z.boolean().optional(),
}),
]);
export const AgentPermissionResponseSchema: z.ZodType<AgentPermissionResponse> =
z.discriminatedUnion("behavior", [
z.object({
behavior: z.literal("allow"),
selectedActionId: z.string().optional(),
updatedInput: z.record(z.string(), z.unknown()).optional(),
updatedPermissions: z.array(AgentPermissionUpdateSchema).optional(),
}),
z.object({
behavior: z.literal("deny"),
selectedActionId: z.string().optional(),
message: z.string().optional(),
interrupt: z.boolean().optional(),
}),
]);
export const AgentPermissionRequestPayloadSchema: z.ZodType<AgentPermissionRequest, unknown> =
z.object({
@@ -553,13 +553,16 @@ const ToolCallCanceledPayloadSchema = ToolCallBasePayloadSchema.extend({
error: z.null(),
});
const ToolCallTimelineItemPayloadSchema: z.ZodType<ToolCallTimelineItem, unknown> = z.union([
ToolCallRunningPayloadSchema,
ToolCallCompletedPayloadSchema,
ToolCallFailedPayloadSchema,
ToolCallCanceledPayloadSchema,
]);
const ToolCallTimelineItemPayloadSchema: z.ZodType<ToolCallTimelineItem, unknown> =
z.discriminatedUnion("status", [
ToolCallRunningPayloadSchema,
ToolCallCompletedPayloadSchema,
ToolCallFailedPayloadSchema,
ToolCallCanceledPayloadSchema,
]);
// zod-aot 0.20.4 miscompiles this as a nested discriminated union by omitting
// the inner tool_call branch from the generated outer dispatch.
export const AgentTimelineItemPayloadSchema: z.ZodType<AgentTimelineItem, unknown> = z.union([
z.object({
type: z.literal("user_message"),
@@ -3112,7 +3115,9 @@ export const SetDaemonConfigResponseMessageSchema = z.object({
export const ReadProjectConfigResponseMessageSchema = z.object({
type: z.literal("read_project_config_response"),
payload: z.discriminatedUnion("ok", [
// zod-aot 0.2.0 miscompiles boolean discriminators as string options
// (`"true"`/`"false"`), so keep this sequential until upstream fixes it.
payload: z.union([
z.object({
requestId: z.string(),
repoRoot: z.string(),
@@ -3131,7 +3136,9 @@ export const ReadProjectConfigResponseMessageSchema = z.object({
export const WriteProjectConfigResponseMessageSchema = z.object({
type: z.literal("write_project_config_response"),
payload: z.discriminatedUnion("ok", [
// zod-aot 0.2.0 miscompiles boolean discriminators as string options
// (`"true"`/`"false"`), so keep this sequential until upstream fixes it.
payload: z.union([
z.object({
requestId: z.string(),
repoRoot: z.string(),
@@ -4672,7 +4679,7 @@ export const WSHelloMessageSchema = z.object({
[CLIENT_CAPS.reasoningMergeEnum]: z.boolean().optional(),
[CLIENT_CAPS.customModeIcons]: z.boolean().optional(),
[CLIENT_CAPS.terminalReflowableSnapshot]: z.boolean().optional(),
[CLIENT_CAPS.desktopBrowserAutomation]: z.boolean().optional(),
[CLIENT_CAPS.browserHost]: BrowserAutomationHostCapabilitySchema.optional(),
})
.passthrough()
.optional(),
+1 -1
View File
@@ -1,5 +1,5 @@
import { z } from "zod";
import { AgentProviderSchema } from "@getpaseo/protocol/provider-manifest";
import { AgentProviderSchema } from "../provider-manifest.js";
export const ScheduleStatusSchema = z.enum(["active", "paused", "completed"]);
export type ScheduleStatus = z.infer<typeof ScheduleStatusSchema>;
+1 -1
View File
@@ -1,5 +1,5 @@
import type { ToolCallTimelineItem } from "./agent-types.js";
import { getPaseoToolLeafName, isPaseoToolName } from "@getpaseo/protocol/tool-name-normalization";
import { getPaseoToolLeafName, isPaseoToolName } from "./tool-name-normalization.js";
import { stripCwdPrefix } from "./path-utils.js";
export type ToolCallDisplayInput = Pick<
@@ -0,0 +1,3 @@
import { WSOutboundMessageSchema as SourceWSOutboundMessageSchema } from "../messages.js";
export const WSOutboundMessageSchema = { schema: SourceWSOutboundMessageSchema };
@@ -0,0 +1,19 @@
import type { z } from "zod";
import { WSOutboundMessageSchema } from "../generated/validation/ws-outbound.aot.js";
import type { WSOutboundMessage } from "../messages.js";
type WSOutboundValidationResult =
| { success: true; data: WSOutboundMessage }
| { success: false; error: z.ZodError };
interface WSOutboundGeneratedValidator {
safeParse(input: unknown): WSOutboundValidationResult;
}
// zod-aot emits runtime JavaScript from WSOutboundMessageSchema but not its TypeScript surface.
// Protocol regression tests cover the patched compiler cases behind this boundary.
const wsOutboundValidator = WSOutboundMessageSchema as WSOutboundGeneratedValidator;
export function validateWSOutboundMessage(input: unknown): WSOutboundValidationResult {
return wsOutboundValidator.safeParse(input);
}
@@ -0,0 +1,145 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { createJiti } from "jiti";
import { describe, expect, it } from "vitest";
import { WSOutboundMessageSchema as GeneratedWSOutboundMessageSchema } from "../../src/generated/validation/ws-outbound.aot.js";
interface GeneratedSchema {
safeParse(input: unknown): { success: boolean; data?: unknown };
}
const protocolRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const generatedWSOutboundPath = resolve(
protocolRoot,
"src/generated/validation/ws-outbound.aot.ts",
);
const require = createRequire(import.meta.url);
async function compileInlineSchema(sourceSchema: string): Promise<GeneratedSchema> {
const scratchRoot = resolve(protocolRoot, "../../.tmp");
await mkdir(scratchRoot, { recursive: true });
const tempDir = await mkdtemp(join(scratchRoot, "paseo-zod-aot-"));
try {
const sourcePath = join(tempDir, "schema.source.js");
const outputPath = join(tempDir, "schema.generated.ts");
await writeFile(join(tempDir, "package.json"), '{"type":"module"}\n');
await writeFile(
sourcePath,
[
'import { z } from "zod";',
'import { compile } from "zod-aot";',
sourceSchema,
"export const Schema = compile(SourceSchema);",
"",
].join("\n"),
);
const zodAotEntry = require.resolve("zod-aot");
const zodAotRoot = resolve(dirname(zodAotEntry), "..");
const [{ discoverSchemas }, { compileSchemas }, { generateCompiledFileContent }] =
await Promise.all([
import(pathToFileURL(resolve(zodAotRoot, "dist/discovery.js")).href),
import(pathToFileURL(resolve(zodAotRoot, "dist/core/pipeline.js")).href),
import(pathToFileURL(resolve(zodAotRoot, "dist/cli/emitter.js")).href),
]);
const schemas = await discoverSchemas(sourcePath, { cacheBust: true });
const compiled = compileSchemas(schemas, { mode: "inline" });
const content = generateCompiledFileContent(compiled, "./schema.source.js", {
zodCompat: false,
});
await writeFile(outputPath, content);
const jiti = createJiti(import.meta.url, { moduleCache: false });
const generated = await jiti.import(outputPath);
return generated.Schema as GeneratedSchema;
} finally {
await rm(tempDir, { recursive: true, force: true });
}
}
describe("WS outbound zod-aot validation", () => {
it("applies defaults inside discriminated-union branches", async () => {
const schema = await compileInlineSchema(`
const SourceSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("with_default"),
enabled: z.boolean().default(true),
}),
z.object({
type: z.literal("without_default"),
label: z.string(),
}),
]);
`);
expect(schema.safeParse({ type: "with_default" })).toMatchObject({
success: true,
data: { type: "with_default", enabled: true },
});
});
it("routes tool-call-like status unions through the current sequential item union", async () => {
const schema = await compileInlineSchema(`
const ToolCallItemSchema = z.discriminatedUnion("status", [
z.object({ type: z.literal("tool_call"), status: z.literal("running"), callId: z.string() }),
z.object({ type: z.literal("tool_call"), status: z.literal("completed"), callId: z.string(), output: z.string() }),
z.object({ type: z.literal("tool_call"), status: z.literal("failed"), callId: z.string(), error: z.string() }),
z.object({ type: z.literal("tool_call"), status: z.literal("canceled"), callId: z.string() }),
]);
const TimelineItemSchema = z.union([
z.object({ type: z.literal("assistant_message"), text: z.string() }),
ToolCallItemSchema,
]);
const SourceSchema = z.object({
item: TimelineItemSchema,
});
`);
expect(
schema.safeParse({ item: { type: "tool_call", status: "running", callId: "run" } }),
).toMatchObject({
success: true,
data: { item: { type: "tool_call", status: "running", callId: "run" } },
});
expect(
schema.safeParse({
item: { type: "tool_call", status: "completed", callId: "done", output: "ok" },
}),
).toMatchObject({
success: true,
data: { item: { type: "tool_call", status: "completed", callId: "done", output: "ok" } },
});
expect(
schema.safeParse({
item: { type: "tool_call", status: "failed", callId: "fail", error: "boom" },
}),
).toMatchObject({
success: true,
data: { item: { type: "tool_call", status: "failed", callId: "fail", error: "boom" } },
});
expect(
schema.safeParse({ item: { type: "tool_call", status: "canceled", callId: "stop" } }),
).toMatchObject({
success: true,
data: { item: { type: "tool_call", status: "canceled", callId: "stop" } },
});
});
it("accepts a minimal valid envelope and rejects a corrupted envelope", () => {
expect(GeneratedWSOutboundMessageSchema.safeParse({ type: "pong" }).success).toBe(true);
expect(GeneratedWSOutboundMessageSchema.safeParse({ type: "not_a_message" }).success).toBe(
false,
);
});
it("emits runtime imports with .js extensions", async () => {
const generated = await readFile(generatedWSOutboundPath, "utf8");
expect(generated).toContain('from "../../validation/ws-outbound-schema-metadata.js"');
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.104-beta.2",
"version": "0.1.104-beta.4",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.104-beta.2",
"version": "0.1.104-beta.4",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -65,10 +65,10 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.3.195",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.1.104-beta.2",
"@getpaseo/highlight": "0.1.104-beta.2",
"@getpaseo/protocol": "0.1.104-beta.2",
"@getpaseo/relay": "0.1.104-beta.2",
"@getpaseo/client": "0.1.104-beta.4",
"@getpaseo/highlight": "0.1.104-beta.4",
"@getpaseo/protocol": "0.1.104-beta.4",
"@getpaseo/relay": "0.1.104-beta.4",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "^1.20.1",
"@opencode-ai/sdk": "1.14.46",
@@ -47,7 +47,10 @@ interface CreateAgentCommandDependencies {
providerSnapshotManager: ProviderSnapshotManager;
createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn;
// Mints a fresh directory workspace for a cwd and returns its id.
ensureWorkspaceForCreate?: (cwd: string) => Promise<string>;
ensureWorkspaceForCreate?: (
cwd: string,
firstAgentContext?: FirstAgentContext,
) => Promise<string>;
}
export interface CreateAgentFromSessionInput {
@@ -253,7 +256,7 @@ async function resolveMcpCreateAgent(
? createdWorkspaceId
: (input.workspaceId ??
parentAgent?.workspaceId ??
(await ensureWorkspaceForMcpCreate(dependencies, resolvedCwd)));
(await ensureWorkspaceForMcpCreate(dependencies, resolvedCwd, input.initialPrompt)));
const { modeId: resolvedMode, featureValues: resolvedFeatures } =
await dependencies.providerSnapshotManager.resolveCreateConfig({
@@ -300,11 +303,12 @@ async function resolveMcpCreateAgent(
async function ensureWorkspaceForMcpCreate(
dependencies: CreateAgentCommandDependencies,
cwd: string,
initialPrompt: string,
): Promise<string | undefined> {
if (!dependencies.ensureWorkspaceForCreate) {
return undefined;
}
return dependencies.ensureWorkspaceForCreate(cwd);
return dependencies.ensureWorkspaceForCreate(cwd, { prompt: initialPrompt });
}
async function sendInitialPrompt(
@@ -424,7 +428,7 @@ async function resolveMcpCwd(params: {
refName: worktree.refName,
action: worktree.action,
githubPrNumber: worktree.githubPrNumber,
...(params.initialPrompt ? { firstAgentContext: { prompt: params.initialPrompt } } : {}),
firstAgentContext: { prompt: params.initialPrompt },
runSetup: false,
paseoHome: dependencies.paseoHome,
worktreesRoot: dependencies.worktreesRoot,
@@ -23,7 +23,11 @@ import {
AgentPermissionRequestPayloadSchema,
AgentSnapshotPayloadSchema,
} from "@getpaseo/protocol/messages";
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "../workspace-registry.js";
import {
createPersistedWorkspaceRecord,
type PersistedProjectRecord,
type PersistedWorkspaceRecord,
} from "../workspace-registry.js";
import type {
CreateScheduleInput,
StoredSchedule,
@@ -35,13 +39,20 @@ import {
createPaseoWorktree as createPaseoWorktreeService,
type CreatePaseoWorktreeInput,
} from "../paseo-worktree-service.js";
import type { CreatePaseoWorktreeWorkflowFn } from "../worktree-session.js";
import {
createPaseoWorktreeWorkflow,
type CreatePaseoWorktreeWorkflowFn,
} from "../worktree-session.js";
import { WorkspaceGitServiceImpl } from "../workspace-git-service.js";
import { WorkspaceAutoName } from "../workspace-auto-name.js";
import { createGitMutationService } from "../session/git-mutation/git-mutation-service.js";
import type { GeneratedWorkspaceName } from "../worktree-branch-name-generator.js";
import type { GitHubService } from "../../services/github-service.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import type { BrowserToolsBroker, BrowserToolsExecuteInput } from "../browser-tools/broker.js";
import type { BrowserToolsResponsePayload } from "../browser-tools/errors.js";
import { readPaseoWorktreeMetadata } from "../../utils/worktree-metadata.js";
const REPO_CWD = resolvePath("/tmp/repo");
const TARGET_CWD = resolvePath("/tmp/target");
@@ -143,6 +154,34 @@ function agentsOf(response: {
return z.array(z.record(z.string(), z.unknown())).parse(response.structuredContent.agents);
}
async function waitForWorkspaceTitle(
workspaceRecords: Map<string, PersistedWorkspaceRecord>,
workspaceId: string,
title: string,
): Promise<void> {
await vi.waitFor(() => expect(workspaceRecords.get(workspaceId)?.title).toBe(title), {
timeout: 5_000,
});
}
async function waitForWorkspaceBranch(
workspaceRecords: Map<string, PersistedWorkspaceRecord>,
workspaceId: string,
branch: string,
): Promise<void> {
await vi.waitFor(() => expect(workspaceRecords.get(workspaceId)?.branch).toBe(branch), {
timeout: 5_000,
});
}
async function waitForUnexpectedWorkspaceNamingSideEffects(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 25));
}
async function removeTempDir(path: string): Promise<void> {
await rm(path, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
type AgentManagerSpies = ReturnType<typeof buildAgentManagerSpies>;
type AgentStorageSpies = ReturnType<typeof buildAgentStorageSpies>;
@@ -612,40 +651,88 @@ function createPaseoWorktreeForMcpTest(options: {
paseoHome: string;
broadcasts: string[];
createdWorkspaceIds?: string[];
workspaceRecords?: Map<string, PersistedWorkspaceRecord>;
generateWorkspaceName?: () => Promise<GeneratedWorkspaceName | null>;
setupContinuations?: Array<"workspace" | "agent" | undefined>;
startedAgentSetupIds?: string[];
}): CreatePaseoWorktreeWorkflowFn {
const projects = new Map<string, PersistedProjectRecord>();
const workspaces = new Map<string, PersistedWorkspaceRecord>();
const workspaces = options.workspaceRecords ?? new Map<string, PersistedWorkspaceRecord>();
const github = createGitHubServiceStub();
const workspaceGitService = new WorkspaceGitServiceImpl({
logger: createTestLogger(),
paseoHome: options.paseoHome,
deps: { github },
});
const workspaceRegistry = {
get: async (workspaceId: string) => workspaces.get(workspaceId) ?? null,
list: async () => Array.from(workspaces.values()),
upsert: async (record: PersistedWorkspaceRecord) => {
workspaces.set(record.workspaceId, record);
},
};
const workspaceAutoName = new WorkspaceAutoName({
agentManager: buildAgentManagerSpies() as unknown as AgentManager,
workspaceRegistry,
workspaceGitService,
providerSnapshotManager: createOpenCodeManager().manager,
readDaemonConfig: () => ({ metadataGeneration: { providers: [] } }),
gitMutation: createGitMutationService({
workspaceGitService,
github,
logger: createTestLogger(),
}),
emitWorkspaceUpdateForCwd: async (cwd) => {
const workspace = Array.from(workspaces.values()).find((record) => record.cwd === cwd);
options.broadcasts.push(z.string().parse(workspace?.workspaceId));
},
emitWorkspaceUpdateForWorkspaceId: async (workspaceId) => {
options.broadcasts.push(workspaceId);
},
logger: createTestLogger(),
generateWorkspaceName: options.generateWorkspaceName ?? (async () => null),
});
return async (input, serviceOptions) => {
options.setupContinuations?.push(serviceOptions?.setupContinuation?.kind);
const result = await createPaseoWorktreeService(input, {
github,
...(serviceOptions?.resolveDefaultBranch
? { resolveDefaultBranch: serviceOptions.resolveDefaultBranch }
: {}),
projectRegistry: {
get: async (projectId) => projects.get(projectId) ?? null,
upsert: async (record) => {
projects.set(record.projectId, record);
const result = await createPaseoWorktreeWorkflow(
{
paseoHome: options.paseoHome,
createPaseoWorktree: (workflowInput, workflowOptions) =>
createPaseoWorktreeService(workflowInput, {
github,
...(workflowOptions?.resolveDefaultBranch
? { resolveDefaultBranch: workflowOptions.resolveDefaultBranch }
: {}),
projectRegistry: {
get: async (projectId) => projects.get(projectId) ?? null,
upsert: async (record) => {
projects.set(record.projectId, record);
},
},
workspaceRegistry,
workspaceGitService,
}),
warmWorkspaceGitData: async () => {},
autoNameWorkspaceBranchForFirstAgent: (autoNameInput) =>
workspaceAutoName.scheduleForWorktree(autoNameInput),
emitWorkspaceUpdateForWorkspaceId: async (workspaceId) => {
options.broadcasts.push(workspaceId);
},
cacheWorkspaceSetupSnapshot: () => {},
emit: () => {},
sessionLogger: createTestLogger(),
terminalManager: null,
archiveWorkspaceRecord: async () => {},
serviceProxy: null,
scriptRuntimeStore: null,
getDaemonTcpPort: null,
getDaemonTcpHost: null,
onScriptsChanged: null,
},
workspaceRegistry: {
get: async (workspaceId) => workspaces.get(workspaceId) ?? null,
list: async () => Array.from(workspaces.values()),
upsert: async (record) => {
workspaces.set(record.workspaceId, record);
},
},
workspaceGitService,
});
input,
serviceOptions,
);
options.broadcasts.push(result.workspace.workspaceId);
options.createdWorkspaceIds?.push(result.workspace.workspaceId);
if (serviceOptions?.setupContinuation?.kind === "agent") {
@@ -679,6 +766,7 @@ describe("browser MCP tools", () => {
agentStorage: agentStorage as AgentStorage,
providerSnapshotManager:
new BoundaryProviderSnapshotManagerFake() as unknown as ProviderSnapshotManager,
browserToolsEnabled: true,
browserToolsBroker: broker as BrowserToolsBroker,
callerAgentId: "agent-1",
logger,
@@ -752,6 +840,7 @@ describe("browser MCP tools", () => {
agentStorage: agentStorage as AgentStorage,
providerSnapshotManager:
new BoundaryProviderSnapshotManagerFake() as unknown as ProviderSnapshotManager,
browserToolsEnabled: true,
browserToolsBroker: broker as BrowserToolsBroker,
callerAgentId: "agent-1",
logger,
@@ -806,50 +895,24 @@ describe("browser MCP tools", () => {
}
});
it("keeps browser tools registered when browser tools are disabled", async () => {
it("does not register browser tools when browser tools are disabled", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.getAgent.mockReturnValue({
id: "agent-1",
cwd: REPO_CWD,
workspaceId: BROWSER_WORKSPACE_ID,
});
const execute = vi.fn().mockResolvedValue({
requestId: "req-browser-disabled",
ok: false,
error: {
code: "browser_disabled",
message: "Browser tools are disabled.",
retryable: false,
},
});
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
browserToolsBroker: { execute } as never,
browserToolsEnabled: false,
callerAgentId: "agent-1",
logger,
});
const tool = registeredTool(server, "browser_list_tabs");
const response = await tool.handler({});
expect(lookupTool(server, "browser_snapshot")).not.toBeUndefined();
expect(execute).toHaveBeenCalledWith({
agentId: "agent-1",
cwd: REPO_CWD,
workspaceId: BROWSER_WORKSPACE_ID,
command: { command: "list_tabs", args: {} },
});
expect(response.structuredContent).toEqual({
ok: false,
error: {
code: "browser_disabled",
message: "Browser tools are disabled.",
retryable: false,
},
context: { agentId: "agent-1", cwd: REPO_CWD, workspaceId: BROWSER_WORKSPACE_ID },
});
expect(lookupTool(server, "browser_list_tabs")).toBeUndefined();
expect(lookupTool(server, "browser_snapshot")).toBeUndefined();
});
it("wires browser tools through the browser tools broker", async () => {
@@ -868,6 +931,7 @@ describe("browser MCP tools", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
browserToolsEnabled: true,
browserToolsBroker: { execute } as never,
callerAgentId: "agent-1",
logger,
@@ -907,6 +971,7 @@ describe("browser MCP tools", () => {
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
browserToolsEnabled: true,
browserToolsBroker: { execute } as never,
callerAgentId: "agent-1",
logger,
@@ -1625,7 +1690,7 @@ describe("create_agent MCP tool", () => {
{ workspaceId: createdWorkspaceIds[0] },
);
} finally {
await rm(tempDir, { recursive: true, force: true });
await removeTempDir(tempDir);
}
});
@@ -1699,20 +1764,406 @@ describe("create_agent MCP tool", () => {
.trim();
expect(initialBranch).not.toBe("");
expect(initialBranch).not.toBe("main");
await new Promise((resolve) => setTimeout(resolve, 0));
await waitForUnexpectedWorkspaceNamingSideEffects();
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
expect(broadcasts).toHaveLength(1);
} finally {
await rm(tempDir, { recursive: true, force: true });
await removeTempDir(tempDir);
}
});
it("does not auto-rename a create_agent checkout worktree from the initial prompt", async () => {
it("auto-titles and renames an agent-created branch-off worktree from the initial prompt", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-worktree-auto-title-"));
const repoDir = join(tempDir, "repo");
const paseoHome = join(tempDir, ".paseo");
const broadcasts: string[] = [];
const createdWorkspaceIds: string[] = [];
const workspaceRecords = new Map<string, PersistedWorkspaceRecord>();
try {
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@example.com"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["config", "commit.gpgsign", "false"], {
cwd: repoDir,
stdio: "pipe",
});
await writeFile(join(repoDir, "README.md"), "hello\n");
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
id: "agent-auto-titled-worktree",
cwd: config.cwd,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Agent title" },
}));
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
paseoHome,
createPaseoWorktree: createPaseoWorktreeForMcpTest({
paseoHome,
broadcasts,
createdWorkspaceIds,
workspaceRecords,
generateWorkspaceName: async () => ({
title: "Workspace Auto Title Flow",
branch: "workspace-auto-title-flow",
}),
}),
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
...detachedWorktreeWorkspace(repoDir, {
kind: "branch-off",
branchName: "feat/placeholder-auto-title",
baseBranch: "main",
}),
title: "Agent title",
provider: "codex/gpt-5.4",
initialPrompt: "Build a workspace auto title flow",
background: true,
});
const workspaceId = z.string().parse(createdWorkspaceIds[0]);
await waitForWorkspaceTitle(workspaceRecords, workspaceId, "Workspace Auto Title Flow");
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
const workspace = workspaceRecords.get(workspaceId);
const branchName = execFileSync("git", ["branch", "--show-current"], {
cwd: agentCwd,
stdio: "pipe",
})
.toString()
.trim();
const metadata = readPaseoWorktreeMetadata(agentCwd);
expect(metadata).toMatchObject({
version: 2,
firstAgentBranchAutoName: {
status: "attempted",
placeholderBranchName: "feat/placeholder-auto-title",
},
});
expect(branchName).toBe("workspace-auto-title-flow");
expect(workspace).toMatchObject({
title: "Workspace Auto Title Flow",
branch: "workspace-auto-title-flow",
});
} finally {
await removeTempDir(tempDir);
}
});
it("keeps a manual workspace title when agent-created worktree naming finishes later", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-worktree-manual-title-"));
const repoDir = join(tempDir, "repo");
const paseoHome = join(tempDir, ".paseo");
const broadcasts: string[] = [];
const createdWorkspaceIds: string[] = [];
const workspaceRecords = new Map<string, PersistedWorkspaceRecord>();
try {
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@example.com"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["config", "commit.gpgsign", "false"], {
cwd: repoDir,
stdio: "pipe",
});
await writeFile(join(repoDir, "README.md"), "hello\n");
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
id: "agent-manual-title-worktree",
cwd: config.cwd,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Agent title" },
}));
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
paseoHome,
createPaseoWorktree: createPaseoWorktreeForMcpTest({
paseoHome,
broadcasts,
createdWorkspaceIds,
workspaceRecords,
generateWorkspaceName: async () => ({
title: "Generated Manual Race Title",
branch: "generated-manual-race-title",
}),
}),
workspaceRegistry: {
get: async (workspaceId) => workspaceRecords.get(workspaceId) ?? null,
upsert: async (record) => {
workspaceRecords.set(record.workspaceId, record);
},
},
emitWorkspaceUpdatesForWorkspaceIds: async (workspaceIds) => {
broadcasts.push(...workspaceIds);
},
logger,
});
const createAgentTool = registeredTool(server, "create_agent");
await createAgentTool.handler({
...detachedWorktreeWorkspace(repoDir, {
kind: "branch-off",
branchName: "feat/manual-title-placeholder",
baseBranch: "main",
}),
title: "Agent title",
provider: "codex/gpt-5.4",
initialPrompt: "Keep the manually renamed workspace title",
background: true,
});
const renameWorkspaceTool = registeredTool(server, "rename_workspace");
await renameWorkspaceTool.handler({
workspaceId: createdWorkspaceIds[0],
title: "Manual Workspace Title",
});
const workspaceId = z.string().parse(createdWorkspaceIds[0]);
await waitForWorkspaceBranch(workspaceRecords, workspaceId, "generated-manual-race-title");
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
const workspace = workspaceRecords.get(workspaceId);
const branchName = execFileSync("git", ["branch", "--show-current"], {
cwd: agentCwd,
stdio: "pipe",
})
.toString()
.trim();
expect(branchName).toBe("generated-manual-race-title");
expect(workspace).toMatchObject({
title: "Manual Workspace Title",
branch: "generated-manual-race-title",
});
} finally {
await removeTempDir(tempDir);
}
});
it("uses create_agent title for the agent while still auto-titling the worktree workspace", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-title-workspace-title-"));
const repoDir = join(tempDir, "repo");
const paseoHome = join(tempDir, ".paseo");
const broadcasts: string[] = [];
const createdWorkspaceIds: string[] = [];
const workspaceRecords = new Map<string, PersistedWorkspaceRecord>();
try {
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@example.com"], {
cwd: repoDir,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Test"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["config", "commit.gpgsign", "false"], {
cwd: repoDir,
stdio: "pipe",
});
await writeFile(join(repoDir, "README.md"), "hello\n");
execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "init"], { cwd: repoDir, stdio: "pipe" });
execFileSync("git", ["branch", "-M", "main"], { cwd: repoDir, stdio: "pipe" });
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
id: "agent-explicit-title-worktree",
cwd: config.cwd,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Explicit Agent Title" },
}));
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
paseoHome,
createPaseoWorktree: createPaseoWorktreeForMcpTest({
paseoHome,
broadcasts,
createdWorkspaceIds,
workspaceRecords,
generateWorkspaceName: async () => ({
title: "Generated Workspace Title",
branch: "generated-workspace-title",
}),
}),
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
...detachedWorktreeWorkspace(repoDir, {
kind: "branch-off",
branchName: "feat/agent-title-placeholder",
baseBranch: "main",
}),
title: "Explicit Agent Title",
provider: "codex/gpt-5.4",
initialPrompt: "Generate the workspace title anyway",
background: true,
});
const workspaceId = z.string().parse(createdWorkspaceIds[0]);
await waitForWorkspaceTitle(workspaceRecords, workspaceId, "Generated Workspace Title");
const workspace = workspaceRecords.get(workspaceId);
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({
title: "Explicit Agent Title",
}),
undefined,
{ workspaceId },
);
expect(workspace).toMatchObject({
title: "Generated Workspace Title",
branch: "generated-workspace-title",
});
} finally {
await removeTempDir(tempDir);
}
});
it("auto-titles an agent-created directory workspace from the initial prompt", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-directory-auto-title-"));
const workspaceDir = join(tempDir, "workspace");
const workspaceRecords = new Map<string, PersistedWorkspaceRecord>();
const broadcasts: string[] = [];
const workspaceGitService = new WorkspaceGitServiceImpl({
logger: createTestLogger(),
paseoHome: join(tempDir, ".paseo"),
deps: { github: createGitHubServiceStub() },
});
const workspaceAutoName = new WorkspaceAutoName({
agentManager,
workspaceRegistry: {
get: async (workspaceId) => workspaceRecords.get(workspaceId) ?? null,
upsert: async (record) => {
workspaceRecords.set(record.workspaceId, record);
},
},
workspaceGitService,
providerSnapshotManager: createOpenCodeManager().manager,
readDaemonConfig: () => ({ metadataGeneration: { providers: [] } }),
gitMutation: createGitMutationService({
workspaceGitService,
github: createGitHubServiceStub(),
logger: createTestLogger(),
}),
emitWorkspaceUpdateForCwd: async () => {},
emitWorkspaceUpdateForWorkspaceId: async (workspaceId) => {
broadcasts.push(workspaceId);
},
logger: createTestLogger(),
generateWorkspaceName: async () => ({
title: "Directory Workspace Title",
branch: "directory-workspace-title",
}),
});
try {
await mkdir(workspaceDir, { recursive: true });
spies.agentManager.createAgent.mockImplementation(async (config: { cwd: string }) => ({
id: "agent-directory-auto-title",
cwd: config.cwd,
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Directory agent" },
}));
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
ensureWorkspaceForCreate: async (cwd, firstAgentContext) => {
const workspace = createPersistedWorkspaceRecord({
workspaceId: "workspace-directory-auto-title",
projectId: "project-directory-auto-title",
cwd,
kind: "directory",
displayName: "workspace",
title: firstAgentContext?.prompt ?? null,
createdAt: "2026-07-03T00:00:00.000Z",
updatedAt: "2026-07-03T00:00:00.000Z",
});
workspaceRecords.set(workspace.workspaceId, workspace);
if (firstAgentContext) {
workspaceAutoName.scheduleForDirectory({
workspaceId: workspace.workspaceId,
cwd: workspace.cwd,
firstAgentContext,
});
}
return workspace.workspaceId;
},
logger,
});
const tool = registeredTool(server, "create_agent");
await tool.handler({
...detachedDirectoryWorkspace(workspaceDir),
title: "Directory agent",
provider: "codex/gpt-5.4",
initialPrompt: "Name a directory workspace from the prompt",
background: true,
});
await waitForWorkspaceTitle(
workspaceRecords,
"workspace-directory-auto-title",
"Directory Workspace Title",
);
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({
cwd: workspaceDir,
title: "Directory agent",
}),
undefined,
{ workspaceId: "workspace-directory-auto-title" },
);
expect(workspaceRecords.get("workspace-directory-auto-title")).toMatchObject({
title: "Directory Workspace Title",
branch: null,
});
expect(broadcasts).toEqual(["workspace-directory-auto-title"]);
} finally {
await removeTempDir(tempDir);
}
});
it("auto-titles without renaming a create_agent checkout worktree from the initial prompt", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const tempDir = await mkdtemp(join(tmpdir(), "paseo-mcp-agent-checkout-name-context-"));
const repoDir = join(tempDir, "repo");
const paseoHome = join(tempDir, ".paseo");
const broadcasts: string[] = [];
const createdWorkspaceIds: string[] = [];
const workspaceRecords = new Map<string, PersistedWorkspaceRecord>();
let generateCalls = 0;
const workspaceGitService = {
getSnapshot: vi.fn(async () => {
throw new Error("agent metadata branch rename should not run");
@@ -1757,7 +2208,19 @@ describe("create_agent MCP tool", () => {
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
paseoHome,
createPaseoWorktree: createPaseoWorktreeForMcpTest({ paseoHome, broadcasts }),
createPaseoWorktree: createPaseoWorktreeForMcpTest({
paseoHome,
broadcasts,
createdWorkspaceIds,
workspaceRecords,
generateWorkspaceName: async () => {
generateCalls += 1;
return {
title: "Generated Checkout Workspace Title",
branch: "generated-checkout-workspace-title",
};
},
}),
workspaceGitService: workspaceGitService as unknown as Pick<
WorkspaceGitService,
"getSnapshot" | "listWorktrees"
@@ -1777,16 +2240,30 @@ describe("create_agent MCP tool", () => {
});
const agentCwd = z.string().parse(spies.agentManager.createAgent.mock.calls[0]?.[0].cwd);
const workspaceId = z.string().parse(createdWorkspaceIds[0]);
await waitForWorkspaceTitle(
workspaceRecords,
workspaceId,
"Generated Checkout Workspace Title",
);
expect(
execFileSync("git", ["branch", "--show-current"], { cwd: agentCwd, stdio: "pipe" })
.toString()
.trim(),
).toBe("existing-feature");
await new Promise((resolve) => setTimeout(resolve, 0));
expect(workspaceRecords.get(workspaceId)).toMatchObject({
title: "Generated Checkout Workspace Title",
branch: "existing-feature",
});
expect(readPaseoWorktreeMetadata(agentCwd)).toMatchObject({
version: 1,
baseRefName: "existing-feature",
});
expect(generateCalls).toBe(1);
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
expect(broadcasts).toHaveLength(1);
expect(broadcasts).toEqual([workspaceId, workspaceId]);
} finally {
await rm(tempDir, { recursive: true, force: true });
await removeTempDir(tempDir);
}
});
@@ -1884,7 +2361,7 @@ describe("create_agent MCP tool", () => {
undefined,
{ workspaceId: "ws-pr-123" },
);
await new Promise((resolve) => setTimeout(resolve, 0));
await waitForUnexpectedWorkspaceNamingSideEffects();
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
});
@@ -1956,7 +2433,7 @@ describe("create_agent MCP tool", () => {
expect(broadcasts).toHaveLength(1);
expect(broadcasts[0]).toMatch(/^wks_[0-9a-f]{16}$/);
} finally {
await rm(tempDir, { recursive: true, force: true });
await removeTempDir(tempDir);
}
});
@@ -2052,7 +2529,7 @@ describe("create_agent MCP tool", () => {
"ws-archive-tool-worktree",
]);
} finally {
await rm(tempDir, { recursive: true, force: true });
await removeTempDir(tempDir);
}
});
@@ -2139,7 +2616,7 @@ describe("create_agent MCP tool", () => {
expect(archivedWorkspaceIds).toContain("ws-mcp-B");
await expect(access(worktreePath)).rejects.toThrow();
} finally {
await rm(tempDir, { recursive: true, force: true });
await removeTempDir(tempDir);
}
});
@@ -2217,7 +2694,7 @@ describe("create_agent MCP tool", () => {
access(z.string().parse(created.structuredContent.worktreePath)),
).rejects.toThrow();
} finally {
await rm(tempDir, { recursive: true, force: true });
await removeTempDir(tempDir);
}
});
@@ -3070,6 +3547,191 @@ describe("update_agent MCP tool", () => {
});
});
describe("rename_workspace MCP tool", () => {
const logger = createTestLogger();
it("renames the caller workspace when workspaceId is omitted", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const workspace = createPersistedWorkspaceRecord({
workspaceId: "wks_parent",
projectId: "proj_parent",
cwd: REPO_CWD,
kind: "local_checkout",
displayName: "main",
createdAt: "2026-07-03T09:00:00.000Z",
updatedAt: "2026-07-03T09:00:00.000Z",
});
const workspaces = new Map([[workspace.workspaceId, workspace]]);
const upsertedWorkspaces: PersistedWorkspaceRecord[] = [];
const emittedWorkspaceIds: string[][] = [];
spies.agentManager.getAgent.mockReturnValue(
createManagedAgent({
id: "parent-agent",
cwd: REPO_CWD,
workspaceId: workspace.workspaceId,
}),
);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
workspaceRegistry: {
get: async (workspaceId) => workspaces.get(workspaceId) ?? null,
upsert: async (record) => {
upsertedWorkspaces.push(record);
workspaces.set(record.workspaceId, record);
},
},
emitWorkspaceUpdatesForWorkspaceIds: async (workspaceIds) => {
emittedWorkspaceIds.push(Array.from(workspaceIds));
},
callerAgentId: "parent-agent",
logger,
});
const tool = registeredTool(server, "rename_workspace");
const response = await invokeToolWithParsedInput(tool, {
title: " Payments flow ",
});
expect(upsertedWorkspaces).toEqual([
{
...workspace,
title: "Payments flow",
updatedAt: expect.any(String),
},
]);
expect(response.structuredContent).toEqual({
success: true,
workspaceId: "wks_parent",
title: "Payments flow",
});
expect(emittedWorkspaceIds).toEqual([["wks_parent"]]);
});
it("renames an explicit workspace outside the caller workspace", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const parentWorkspace = createPersistedWorkspaceRecord({
workspaceId: "wks_parent",
projectId: "proj_parent",
cwd: REPO_CWD,
kind: "local_checkout",
displayName: "main",
createdAt: "2026-07-03T09:00:00.000Z",
updatedAt: "2026-07-03T09:00:00.000Z",
});
const otherWorkspace = createPersistedWorkspaceRecord({
workspaceId: "wks_other",
projectId: "proj_other",
cwd: TARGET_CWD,
kind: "local_checkout",
displayName: "other",
createdAt: "2026-07-03T09:00:00.000Z",
updatedAt: "2026-07-03T09:00:00.000Z",
});
const workspaces = new Map([
[parentWorkspace.workspaceId, parentWorkspace],
[otherWorkspace.workspaceId, otherWorkspace],
]);
const upsertedWorkspaces: PersistedWorkspaceRecord[] = [];
const emittedWorkspaceIds: string[][] = [];
spies.agentManager.getAgent.mockReturnValue(
createManagedAgent({
id: "parent-agent",
cwd: REPO_CWD,
workspaceId: parentWorkspace.workspaceId,
}),
);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
workspaceRegistry: {
get: async (workspaceId) => workspaces.get(workspaceId) ?? null,
upsert: async (record) => {
upsertedWorkspaces.push(record);
workspaces.set(record.workspaceId, record);
},
},
emitWorkspaceUpdatesForWorkspaceIds: async (workspaceIds) => {
emittedWorkspaceIds.push(Array.from(workspaceIds));
},
callerAgentId: "parent-agent",
logger,
});
const tool = registeredTool(server, "rename_workspace");
const response = await invokeToolWithParsedInput(tool, {
workspaceId: "wks_other",
title: "Payments flow",
});
expect(upsertedWorkspaces).toEqual([
{
...otherWorkspace,
title: "Payments flow",
updatedAt: expect.any(String),
},
]);
expect(response.structuredContent).toEqual({
success: true,
workspaceId: "wks_other",
title: "Payments flow",
});
expect(emittedWorkspaceIds).toEqual([["wks_other"]]);
});
it("rejects archived workspaces", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
const workspace = createPersistedWorkspaceRecord({
workspaceId: "wks_archived",
projectId: "proj_parent",
cwd: REPO_CWD,
kind: "local_checkout",
displayName: "main",
archivedAt: "2026-07-03T10:00:00.000Z",
createdAt: "2026-07-03T09:00:00.000Z",
updatedAt: "2026-07-03T10:00:00.000Z",
});
const workspaces = new Map([[workspace.workspaceId, workspace]]);
const upsertedWorkspaces: PersistedWorkspaceRecord[] = [];
const emittedWorkspaceIds: string[][] = [];
spies.agentManager.getAgent.mockReturnValue(
createManagedAgent({
id: "parent-agent",
cwd: REPO_CWD,
workspaceId: workspace.workspaceId,
}),
);
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
workspaceRegistry: {
get: async (workspaceId) => workspaces.get(workspaceId) ?? null,
upsert: async (record) => {
upsertedWorkspaces.push(record);
workspaces.set(record.workspaceId, record);
},
},
emitWorkspaceUpdatesForWorkspaceIds: async (workspaceIds) => {
emittedWorkspaceIds.push(Array.from(workspaceIds));
},
callerAgentId: "parent-agent",
logger,
});
const tool = registeredTool(server, "rename_workspace");
await expect(
invokeToolWithParsedInput(tool, {
title: "Payments flow",
}),
).rejects.toThrow("Workspace wks_archived is archived");
expect(upsertedWorkspaces).toEqual([]);
expect(emittedWorkspaceIds).toEqual([]);
});
});
describe("create_schedule MCP tool", () => {
const logger = createTestLogger();
@@ -25,6 +25,11 @@ const mockState = vi.hoisted(() => {
env?: Record<string, string>;
providerParams?: unknown;
}>,
trae: [] as Array<{
command: string[];
env?: Record<string, string>;
providerParams?: unknown;
}>,
pi: [] as ConstructorEntry[],
genericAcp: [] as Array<{
command: string[];
@@ -41,6 +46,7 @@ const mockState = vi.hoisted(() => {
this.constructorArgs.codex = [];
this.constructorArgs.copilot = [];
this.constructorArgs.cursor = [];
this.constructorArgs.trae = [];
this.constructorArgs.pi = [];
this.constructorArgs.genericAcp = [];
this.isCommandAvailable.mockReset();
@@ -375,6 +381,59 @@ vi.mock("./providers/cursor-acp-agent.js", () => ({
},
}));
vi.mock("./providers/trae-acp-agent.js", () => ({
TraeACPAgentClient: class TraeACPAgentClient {
readonly capabilities = {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
};
readonly provider = "acp";
readonly runtimeSettings?: unknown;
constructor(options: {
command: string[];
env?: Record<string, string>;
providerParams?: unknown;
}) {
this.runtimeSettings = {
command: {
mode: "replace",
argv: options.command,
},
env: options.env,
};
mockState.constructorArgs.trae.push({
command: options.command,
env: options.env,
providerParams: options.providerParams,
});
}
async createSession(): Promise<never> {
throw new Error("not implemented");
}
async resumeSession(): Promise<never> {
throw new Error("not implemented");
}
async fetchCatalog(): Promise<ProviderCatalog> {
return {
models: mockState.runtimeModels.get(this.provider) ?? [],
modes: [],
};
}
async isAvailable(): Promise<boolean> {
return true;
}
},
}));
import {
AGENT_PROVIDER_DEFINITIONS,
buildProviderRegistry,
@@ -638,6 +697,33 @@ test("cursor provider extending acp uses CursorACPAgentClient", () => {
expect(mockState.constructorArgs.genericAcp).toEqual([]);
});
test("traecli provider extending acp uses TraeACPAgentClient", () => {
const registry = buildProviderRegistry(logger, {
providerOverrides: {
traecli: {
extends: "acp",
label: "TRAE CLI",
command: ["traecli", "acp", "serve"],
},
},
});
expect(registry.traecli.createClient(logger).provider).toBe("traecli");
expect(mockState.constructorArgs.trae).toEqual([
{
command: ["traecli", "acp", "serve"],
env: undefined,
providerParams: undefined,
},
{
command: ["traecli", "acp", "serve"],
env: undefined,
providerParams: undefined,
},
]);
expect(mockState.constructorArgs.genericAcp).toEqual([]);
});
test('extends: "acp" without command throws', () => {
expect(() =>
buildProviderRegistry(logger, {
@@ -36,6 +36,7 @@ import { GenericACPAgentClient } from "./providers/generic-acp-agent.js";
import { KiroACPAgentClient } from "./providers/kiro-acp-agent.js";
import { OpenCodeAgentClient } from "./providers/opencode-agent.js";
import { PiRpcAgentClient } from "./providers/pi/agent.js";
import { TraeACPAgentClient } from "./providers/trae-acp-agent.js";
import { MockLoadTestAgentClient } from "./providers/mock-load-test-agent.js";
import { MockSlowProviderClient } from "./providers/mock-slow-provider.js";
import {
@@ -660,6 +661,9 @@ function addDerivedProviders(
if (providerId === "kiro") {
return new KiroACPAgentClient(acpOptions);
}
if (providerId === "traecli") {
return new TraeACPAgentClient(acpOptions);
}
return new GenericACPAgentClient(acpOptions);
},
});
@@ -46,6 +46,7 @@ import { transformPiModels } from "./pi/agent.js";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
import type { AgentCapabilityFlags, AgentPersistenceHandle } from "../agent-sdk-types.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { buildStringCommandShellInvocation } from "../../../utils/string-command-shell.js";
import { asInternals } from "../../test-utils/class-mocks.js";
import * as spawnUtils from "../../../utils/spawn.js";
@@ -540,7 +541,10 @@ describe("ACPAgentSession terminal tools", () => {
const child = createTerminalChildStub();
const spawn = vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
const session = createSession();
const shell = spawnUtils.platformShell();
const shell = buildStringCommandShellInvocation({
command: "git -C /repo status --short",
windowsShell: "cmd",
});
await session.createTerminal({
sessionId: "session-1",
@@ -549,12 +553,50 @@ describe("ACPAgentSession terminal tools", () => {
});
expect(spawn).toHaveBeenCalledWith(
shell.command,
[...shell.flag, "git -C /repo status --short"],
expect.objectContaining({ cwd: "/repo" }),
shell.shell,
shell.args,
expect.objectContaining({
cwd: "/repo",
envOverlay: expect.objectContaining({ BASH_ENV: undefined }),
shell: false,
}),
);
});
test("preserves cmd semantics for single-string terminal commands on Windows", async () => {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", {
value: "win32",
configurable: true,
});
try {
const child = createTerminalChildStub();
const spawn = vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
const session = createSession();
await session.createTerminal({
sessionId: "session-1",
command: "echo %TEMP% && echo ok",
cwd: "C:\\repo",
});
expect(spawn).toHaveBeenCalledWith(
"cmd.exe",
["/c", "echo %TEMP% && echo ok"],
expect.objectContaining({
cwd: "C:\\repo",
envOverlay: expect.objectContaining({ BASH_ENV: undefined }),
shell: false,
}),
);
} finally {
Object.defineProperty(process, "platform", {
value: originalPlatform,
configurable: true,
});
}
});
test("preserves explicit terminal argv", async () => {
const child = createTerminalChildStub();
const spawn = vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
@@ -102,7 +102,11 @@ import {
} from "../provider-launch-config.js";
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js";
import { platformShell, spawnProcess } from "../../../utils/spawn.js";
import {
buildStringCommandShellInvocation,
createStringCommandShellEnvOverlay,
} from "../../../utils/string-command-shell.js";
import { spawnProcess } from "../../../utils/spawn.js";
import {
type DiagnosticEntry,
toDiagnosticErrorMessage,
@@ -181,7 +185,7 @@ function toACPRequestError(error: unknown): Error {
function resolveTerminalCommand(
command: string,
args?: string[],
): { command: string; args: string[] } {
): { command: string; args: string[]; shell?: boolean } {
if (args && args.length > 0) {
return { command, args };
}
@@ -190,8 +194,8 @@ function resolveTerminalCommand(
return { command, args: [] };
}
const shell = platformShell();
return { command: shell.command, args: [...shell.flag, command] };
const shell = buildStringCommandShellInvocation({ command, windowsShell: "cmd" });
return { command: shell.shell, args: shell.args, shell: false };
}
function formatDurationMs(startedAt: number): string {
@@ -2159,12 +2163,15 @@ export class ACPAgentSession implements AgentSession, ACPClient {
(params.env ?? []).map((entry: EnvVariable) => [entry.name, entry.value]),
);
const terminalCommand = resolveTerminalCommand(params.command, params.args);
const commandEnvOverlays =
terminalCommand.shell === false ? [env, createStringCommandShellEnvOverlay()] : [env];
const child = spawnProcess(terminalCommand.command, terminalCommand.args, {
cwd: params.cwd ?? this.config.cwd,
...createProviderEnvSpec({
runtimeSettings: this.runtimeSettings,
overlays: [env],
overlays: commandEnvOverlays,
}),
shell: terminalCommand.shell,
stdio: ["ignore", "pipe", "pipe"],
});
@@ -438,7 +438,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
}
});
test("exposes Ultracode only on Claude models that support it", async () => {
test("exposes Ultra Code on xhigh-capable Claude models", async () => {
const emptyConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-models-empty-"));
try {
const client = new ClaudeAgentClient({
@@ -459,8 +459,9 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
expect(getThinkingIds("claude-opus-4-8[1m]")).toContain("ultracode");
expect(getThinkingIds("claude-opus-4-8")).toContain("ultracode");
expect(getThinkingIds("claude-sonnet-5")).toContain("xhigh");
expect(getThinkingIds("claude-sonnet-5")).not.toContain("ultracode");
expect(getThinkingIds("claude-opus-4-7")).not.toContain("ultracode");
expect(getThinkingIds("claude-sonnet-5")).toContain("ultracode");
expect(getThinkingIds("claude-opus-4-7[1m]")).toContain("ultracode");
expect(getThinkingIds("claude-opus-4-7")).toContain("ultracode");
expect(getThinkingIds("claude-sonnet-4-6")).not.toContain("ultracode");
} finally {
await fs.rm(emptyConfigDir, { recursive: true, force: true });
@@ -597,6 +598,30 @@ describe("ClaudeAgentSession features", () => {
}),
).resolves.toEqual([expect.objectContaining({ id: "fast_mode", value: false })]);
await expect(
client.listFeatures({
provider: "claude",
cwd: process.cwd(),
model: "claude-opus-4-8[1m]",
}),
).resolves.toEqual([expect.objectContaining({ id: "fast_mode", value: false })]);
await expect(
client.listFeatures({
provider: "claude",
cwd: process.cwd(),
model: "claude-opus-4-8-20260101",
}),
).resolves.toEqual([expect.objectContaining({ id: "fast_mode", value: false })]);
await expect(
client.listFeatures({
provider: "claude",
cwd: process.cwd(),
model: "openrouter/anthropic/claude-opus-4-8",
}),
).resolves.toEqual([]);
await expect(
client.listFeatures({
provider: "claude",
@@ -34,6 +34,7 @@ import {
getClaudeModelsWithSettings,
normalizeClaudeRuntimeModelId,
} from "./models.js";
import { CLAUDE_ULTRACODE_THINKING_OPTION_ID } from "./model-manifest.js";
import { parsePartialJsonObject } from "./partial-json.js";
import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
import { buildClaudeFeatures, claudeModelSupportsFastMode } from "./feature-definitions.js";
@@ -373,7 +374,7 @@ interface ClaudeAgentSessionOptions {
}
type ClaudeThinkingEffort = "low" | "medium" | "high" | "xhigh" | "max";
type ClaudeThinkingOption = ClaudeThinkingEffort | "ultracode";
type ClaudeThinkingOption = ClaudeThinkingEffort | typeof CLAUDE_ULTRACODE_THINKING_OPTION_ID;
function resolvePathEnvKey(): "Path" | "PATH" | null {
if (process.env["Path"] !== undefined) return "Path";
@@ -421,7 +422,7 @@ function isClaudeThinkingEffort(value: string | null | undefined): value is Clau
}
function isClaudeThinkingOption(value: string | null | undefined): value is ClaudeThinkingOption {
return value === "ultracode" || isClaudeThinkingEffort(value);
return value === CLAUDE_ULTRACODE_THINKING_OPTION_ID || isClaudeThinkingEffort(value);
}
interface ClaudeOptionsLogSummary {
@@ -2864,7 +2865,7 @@ class ClaudeAgentSession implements AgentSession {
this.config.thinkingOptionId && this.config.thinkingOptionId !== "default"
? this.config.thinkingOptionId
: undefined;
if (thinkingOptionId === "ultracode") {
if (thinkingOptionId === CLAUDE_ULTRACODE_THINKING_OPTION_ID) {
return { thinking: { type: "adaptive" }, effort: "xhigh", ultracode: true };
}
if (thinkingOptionId && isClaudeThinkingEffort(thinkingOptionId)) {
@@ -1,10 +1,5 @@
import type { AgentFeature, AgentFeatureToggle } from "../../agent-sdk-types.js";
const CLAUDE_FAST_MODE_SUPPORTED_MODEL_PREFIXES = [
"claude-opus-4-8",
"claude-opus-4-7",
"claude-opus-4-6",
] as const;
import { claudeManifestModelSupportsFastMode } from "./model-manifest.js";
export const CLAUDE_FAST_MODE_FEATURE: Omit<AgentFeatureToggle, "value"> = {
type: "toggle",
@@ -15,20 +10,8 @@ export const CLAUDE_FAST_MODE_FEATURE: Omit<AgentFeatureToggle, "value"> = {
icon: "zap",
};
function normalizeClaudeModelId(modelId: string | null | undefined): string | null {
const normalized = typeof modelId === "string" ? modelId.trim() : "";
return normalized.length > 0 ? normalized : null;
}
export function claudeModelSupportsFastMode(modelId: string | null | undefined): boolean {
const normalizedModelId = normalizeClaudeModelId(modelId);
if (!normalizedModelId) {
return false;
}
return CLAUDE_FAST_MODE_SUPPORTED_MODEL_PREFIXES.some(
(prefix) => normalizedModelId === prefix || normalizedModelId.startsWith(`${prefix}[`),
);
return claudeManifestModelSupportsFastMode(modelId);
}
export function buildClaudeFeatures(input: {
@@ -0,0 +1,282 @@
import type { AgentModelDefinition, AgentSelectOption } from "../../agent-sdk-types.js";
type ClaudeEffortLevel = "low" | "medium" | "high" | "xhigh" | "max";
interface ClaudeModelManifestEntry {
id: string;
label: string;
description: string;
isDefault?: boolean;
contextWindowMaxTokens?: number;
effortLevels?: readonly ClaudeEffortLevel[];
supportsFastMode?: boolean;
}
const CLAUDE_EFFORT_LEVELS = {
standard: ["low", "medium", "high", "max"],
xhigh: ["low", "medium", "high", "xhigh", "max"],
} as const satisfies Record<string, readonly ClaudeEffortLevel[]>;
const CLAUDE_EFFORT_LABELS = {
low: "Low",
medium: "Medium",
high: "High",
xhigh: "Extra High",
max: "Max",
} as const satisfies Record<ClaudeEffortLevel, string>;
export const CLAUDE_ULTRACODE_THINKING_OPTION_ID = "ultracode";
export const CLAUDE_MODEL_MANIFEST = [
{
id: "claude-fable-5",
label: "Fable 5",
description: "Fable 5 · Most powerful model",
contextWindowMaxTokens: 1_000_000,
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
},
{
id: "claude-opus-4-8[1m]",
label: "Opus 4.8 1M",
description: "Opus 4.8 with 1M context window",
contextWindowMaxTokens: 1_000_000,
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
supportsFastMode: true,
},
{
id: "claude-opus-4-8",
label: "Opus 4.8",
description: "Opus 4.8 · Latest release",
isDefault: true,
contextWindowMaxTokens: 200_000,
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
supportsFastMode: true,
},
{
id: "claude-sonnet-5",
label: "Sonnet 5",
description: "Sonnet 5 · Best for everyday tasks",
contextWindowMaxTokens: 1_000_000,
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
},
{
id: "claude-opus-4-7[1m]",
label: "Opus 4.7 1M",
description: "Opus 4.7 with 1M context window",
contextWindowMaxTokens: 1_000_000,
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
supportsFastMode: true,
},
{
id: "claude-opus-4-7",
label: "Opus 4.7",
description: "Opus 4.7 · Previous release",
contextWindowMaxTokens: 200_000,
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
supportsFastMode: true,
},
{
id: "claude-opus-4-6[1m]",
label: "Opus 4.6 1M",
description: "Opus 4.6 with 1M context window",
contextWindowMaxTokens: 1_000_000,
effortLevels: CLAUDE_EFFORT_LEVELS.standard,
supportsFastMode: true,
},
{
id: "claude-opus-4-6",
label: "Opus 4.6",
description: "Opus 4.6 · Most capable for complex work",
contextWindowMaxTokens: 200_000,
effortLevels: CLAUDE_EFFORT_LEVELS.standard,
supportsFastMode: true,
},
{
id: "claude-sonnet-4-6[1m]",
label: "Sonnet 4.6 1M",
description: "Sonnet 4.6 with 1M context window",
contextWindowMaxTokens: 1_000_000,
effortLevels: CLAUDE_EFFORT_LEVELS.standard,
},
{
id: "claude-sonnet-4-6",
label: "Sonnet 4.6",
description: "Sonnet 4.6 · Best for everyday tasks",
contextWindowMaxTokens: 200_000,
effortLevels: CLAUDE_EFFORT_LEVELS.standard,
},
{
id: "claude-haiku-4-5",
label: "Haiku 4.5",
description: "Haiku 4.5 · Fastest for quick answers",
contextWindowMaxTokens: 200_000,
},
] as const satisfies readonly ClaudeModelManifestEntry[];
function buildThinkingOptions(
effortLevels: readonly ClaudeEffortLevel[] | undefined,
): AgentSelectOption[] | undefined {
if (!effortLevels) {
return undefined;
}
const options: AgentSelectOption[] = effortLevels.map((id) => ({
id,
label: CLAUDE_EFFORT_LABELS[id],
}));
if (effortLevels.includes("xhigh")) {
options.push({ id: CLAUDE_ULTRACODE_THINKING_OPTION_ID, label: "Ultra Code" });
}
return options;
}
export function getClaudeManifestModels(): AgentModelDefinition[] {
return CLAUDE_MODEL_MANIFEST.map((model) => {
const thinkingOptions = buildThinkingOptions(
"effortLevels" in model ? model.effortLevels : undefined,
);
return {
provider: "claude",
id: model.id,
label: model.label,
description: model.description,
...("isDefault" in model && model.isDefault ? { isDefault: true } : {}),
...(model.contextWindowMaxTokens !== undefined
? { contextWindowMaxTokens: model.contextWindowMaxTokens }
: {}),
...(thinkingOptions ? { thinkingOptions } : {}),
};
});
}
export function isClaudeManifestModelId(modelId: string): boolean {
return CLAUDE_MODEL_MANIFEST.some((model) => model.id === modelId);
}
export function claudeManifestModelSupportsFastMode(modelId: string | null | undefined): boolean {
const normalizedModelId = normalizeClaudeManifestModelId(modelId);
if (!normalizedModelId) {
return false;
}
return CLAUDE_MODEL_MANIFEST.some(
(model) =>
model.id === normalizedModelId &&
"supportsFastMode" in model &&
model.supportsFastMode === true,
);
}
/**
* Normalize first-party Claude model IDs for manifest capability checks. Provider-prefixed
* runtime IDs intentionally use normalizeClaudeRuntimeModelId instead.
*/
export function normalizeClaudeManifestModelId(value: string | null | undefined): string | null {
const trimmed = typeof value === "string" ? value.trim() : "";
if (!trimmed) {
return null;
}
if (isClaudeManifestModelId(trimmed)) {
return trimmed;
}
const singleSegmentMatch = trimmed.match(
/^(?:claude[-_ ])?(fable|opus|sonnet|haiku)[-_ ]+(\d+)(\[1m\])?(?:[-_ ]+\d{8})?$/i,
);
if (singleSegmentMatch) {
return normalizeSingleSegmentClaudeModelId(
singleSegmentMatch[1],
singleSegmentMatch[2],
trimmed.toLowerCase().includes("[1m]"),
);
}
const runtimeMatch = trimmed.match(
/^(?:claude[-_ ])?(opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?(?:[-_ ]+\d{8})?$/i,
);
if (!runtimeMatch) {
return null;
}
return normalizeMajorMinorClaudeModelId(
runtimeMatch[1],
runtimeMatch[2],
runtimeMatch[3],
Boolean(runtimeMatch[4]),
);
}
/**
* Normalize a Claude Code runtime/config model string to a known manifest ID.
* Runtime metadata may include provider prefixes such as Bedrock model IDs; feature
* gates should use normalizeClaudeManifestModelId instead.
*/
export function normalizeClaudeRuntimeModelId(value: string | null | undefined): string | null {
const normalizedManifestModelId = normalizeClaudeManifestModelId(value);
if (normalizedManifestModelId) {
return normalizedManifestModelId;
}
const trimmed = typeof value === "string" ? value.trim() : "";
if (!trimmed) {
return null;
}
const singleSegmentMatch = trimmed.match(
/claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)(\[1m\])?/i,
);
if (singleSegmentMatch) {
const normalizedModelId = normalizeSingleSegmentClaudeModelId(
singleSegmentMatch[1],
singleSegmentMatch[2],
Boolean(singleSegmentMatch[3]),
);
if (normalizedModelId) {
return normalizedModelId;
}
}
const runtimeMatch = trimmed.match(
/claude[-_ ](opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?/i,
);
if (!runtimeMatch) {
return null;
}
return normalizeMajorMinorClaudeModelId(
runtimeMatch[1],
runtimeMatch[2],
runtimeMatch[3],
Boolean(runtimeMatch[4]),
);
}
function normalizeSingleSegmentClaudeModelId(
familyValue: string,
major: string,
hasOneMillionContext: boolean,
): string | null {
const family = familyValue.toLowerCase();
const suffix = hasOneMillionContext ? "[1m]" : "";
const candidates = [`claude-${family}-${major}${suffix}`, `claude-${family}-${major}`];
for (const candidate of candidates) {
if (isClaudeManifestModelId(candidate)) {
return candidate;
}
}
return null;
}
function normalizeMajorMinorClaudeModelId(
familyValue: string,
major: string,
minor: string,
hasOneMillionContext: boolean,
): string | null {
const family = familyValue.toLowerCase();
const suffix = hasOneMillionContext ? "[1m]" : "";
const candidate = `claude-${family}-${major}-${minor}${suffix}`;
return isClaudeManifestModelId(candidate) ? candidate : null;
}
@@ -5,6 +5,11 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { createTestLogger } from "../../../../test-utils/test-logger.js";
import { ClaudeAgentClient } from "./agent.js";
import {
CLAUDE_ULTRACODE_THINKING_OPTION_ID,
claudeManifestModelSupportsFastMode,
normalizeClaudeManifestModelId,
} from "./model-manifest.js";
import { findClaudeModel, getClaudeModels, normalizeClaudeRuntimeModelId } from "./models.js";
const createdClaudeConfigDirs: string[] = [];
@@ -78,6 +83,41 @@ describe("getClaudeModels", () => {
);
});
it("derives thinking options from model effort capabilities", () => {
const models = new Map(getClaudeModels().map((model) => [model.id, model]));
expect(models.get("claude-sonnet-5")?.thinkingOptions?.map((option) => option.id)).toEqual([
"low",
"medium",
"high",
"xhigh",
"max",
CLAUDE_ULTRACODE_THINKING_OPTION_ID,
]);
expect(
models
.get("claude-sonnet-5")
?.thinkingOptions?.find((option) => option.id === CLAUDE_ULTRACODE_THINKING_OPTION_ID)
?.label,
).toBe("Ultra Code");
expect(models.get("claude-opus-4-7")?.thinkingOptions?.map((option) => option.id)).toEqual([
"low",
"medium",
"high",
"xhigh",
"max",
CLAUDE_ULTRACODE_THINKING_OPTION_ID,
]);
expect(models.get("claude-sonnet-4-6")?.thinkingOptions?.map((option) => option.id)).toEqual([
"low",
"medium",
"high",
"max",
]);
expect(models.get("claude-haiku-4-5")?.thinkingOptions).toBeUndefined();
});
it("returns fresh copies each call", () => {
const a = getClaudeModels();
const b = getClaudeModels();
@@ -254,11 +294,34 @@ describe("normalizeClaudeRuntimeModelId", () => {
expect(normalizeClaudeRuntimeModelId("gpt-5")).toBeNull();
expect(normalizeClaudeRuntimeModelId("random")).toBeNull();
});
it("normalizes provider-form runtime model strings", () => {
expect(normalizeClaudeRuntimeModelId("openrouter/anthropic/claude-opus-4-8")).toBe(
"claude-opus-4-8",
);
expect(normalizeClaudeRuntimeModelId("us.anthropic.claude-opus-4-8[1m]")).toBe(
"claude-opus-4-8[1m]",
);
expect(normalizeClaudeRuntimeModelId("us.anthropic.claude-opus-4-8-20260101")).toBe(
"claude-opus-4-8",
);
});
});
describe("findClaudeModel", () => {
it("resolves runtime model IDs to catalog entries", () => {
expect(findClaudeModel("claude-sonnet-5-20260101")?.id).toBe("claude-sonnet-5");
expect(findClaudeModel("claude-sonnet-5[1m]")?.contextWindowMaxTokens).toBe(1_000_000);
expect(findClaudeModel("us.anthropic.claude-opus-4-8[1m]")?.contextWindowMaxTokens).toBe(
1_000_000,
);
});
});
describe("claudeManifestModelSupportsFastMode", () => {
it("keeps fast mode strict to first-party manifest model IDs", () => {
expect(normalizeClaudeManifestModelId("openrouter/anthropic/claude-opus-4-8")).toBeNull();
expect(claudeManifestModelSupportsFastMode("openrouter/anthropic/claude-opus-4-8")).toBe(false);
expect(claudeManifestModelSupportsFastMode("claude-opus-4-8-20260101")).toBe(true);
});
});
@@ -4,117 +4,10 @@ import * as path from "node:path";
import type { Logger } from "pino";
import type { AgentModelDefinition } from "../../agent-sdk-types.js";
const CLAUDE_THINKING_OPTIONS = [
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
{ id: "high", label: "High" },
{ id: "max", label: "Max" },
] as const;
const CLAUDE_EXTENDED_THINKING_OPTIONS = [
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
{ id: "high", label: "High" },
{ id: "xhigh", label: "Extra High" },
{ id: "max", label: "Max" },
] as const;
const CLAUDE_ULTRACODE_THINKING_OPTIONS = [
...CLAUDE_EXTENDED_THINKING_OPTIONS,
{ id: "ultracode", label: "Ultracode" },
] as const;
const CLAUDE_MODELS: AgentModelDefinition[] = [
{
provider: "claude",
id: "claude-fable-5",
label: "Fable 5",
description: "Fable 5 · Most powerful model",
contextWindowMaxTokens: 1_000_000,
thinkingOptions: [...CLAUDE_ULTRACODE_THINKING_OPTIONS],
},
{
provider: "claude",
id: "claude-opus-4-8[1m]",
label: "Opus 4.8 1M",
description: "Opus 4.8 with 1M context window",
contextWindowMaxTokens: 1_000_000,
thinkingOptions: [...CLAUDE_ULTRACODE_THINKING_OPTIONS],
},
{
provider: "claude",
id: "claude-opus-4-8",
label: "Opus 4.8",
description: "Opus 4.8 · Latest release",
isDefault: true,
contextWindowMaxTokens: 200_000,
thinkingOptions: [...CLAUDE_ULTRACODE_THINKING_OPTIONS],
},
{
provider: "claude",
id: "claude-sonnet-5",
label: "Sonnet 5",
description: "Sonnet 5 · Best for everyday tasks",
contextWindowMaxTokens: 1_000_000,
thinkingOptions: [...CLAUDE_EXTENDED_THINKING_OPTIONS],
},
{
provider: "claude",
id: "claude-opus-4-7[1m]",
label: "Opus 4.7 1M",
description: "Opus 4.7 with 1M context window",
contextWindowMaxTokens: 1_000_000,
thinkingOptions: [...CLAUDE_EXTENDED_THINKING_OPTIONS],
},
{
provider: "claude",
id: "claude-opus-4-7",
label: "Opus 4.7",
description: "Opus 4.7 · Previous release",
contextWindowMaxTokens: 200_000,
thinkingOptions: [...CLAUDE_EXTENDED_THINKING_OPTIONS],
},
{
provider: "claude",
id: "claude-opus-4-6[1m]",
label: "Opus 4.6 1M",
description: "Opus 4.6 with 1M context window",
contextWindowMaxTokens: 1_000_000,
thinkingOptions: [...CLAUDE_THINKING_OPTIONS],
},
{
provider: "claude",
id: "claude-opus-4-6",
label: "Opus 4.6",
description: "Opus 4.6 · Most capable for complex work",
contextWindowMaxTokens: 200_000,
thinkingOptions: [...CLAUDE_THINKING_OPTIONS],
},
{
provider: "claude",
id: "claude-sonnet-4-6[1m]",
label: "Sonnet 4.6 1M",
description: "Sonnet 4.6 with 1M context window",
contextWindowMaxTokens: 1_000_000,
thinkingOptions: [...CLAUDE_THINKING_OPTIONS],
},
{
provider: "claude",
id: "claude-sonnet-4-6",
label: "Sonnet 4.6",
description: "Sonnet 4.6 · Best for everyday tasks",
contextWindowMaxTokens: 200_000,
thinkingOptions: [...CLAUDE_THINKING_OPTIONS],
},
{
provider: "claude",
id: "claude-haiku-4-5",
label: "Haiku 4.5",
description: "Haiku 4.5 · Fastest for quick answers",
contextWindowMaxTokens: 200_000,
},
];
import {
getClaudeManifestModels,
normalizeClaudeRuntimeModelId as normalizeClaudeManifestRuntimeModelId,
} from "./model-manifest.js";
const CLAUDE_SETTINGS_MODEL_ENV_KEYS = [
"ANTHROPIC_MODEL",
@@ -125,7 +18,7 @@ const CLAUDE_SETTINGS_MODEL_ENV_KEYS = [
] as const;
export function getClaudeModels(): AgentModelDefinition[] {
return CLAUDE_MODELS.map((model) => ({ ...model }));
return getClaudeManifestModels();
}
export function findClaudeModel(
@@ -135,7 +28,7 @@ export function findClaudeModel(
if (!normalizedModelId) {
return undefined;
}
return CLAUDE_MODELS.find((model) => model.id === normalizedModelId);
return getClaudeModels().find((model) => model.id === normalizedModelId);
}
export async function getClaudeModelsWithSettings(
@@ -236,42 +129,5 @@ function isRecord(value: unknown): value is Record<string, unknown> {
* Handles the `[1m]` suffix that the SDK appends for 1M context sessions.
*/
export function normalizeClaudeRuntimeModelId(value: string | null | undefined): string | null {
const trimmed = typeof value === "string" ? value.trim() : "";
if (!trimmed) {
return null;
}
// Check for exact match first (handles claude-opus-4-6[1m] directly)
if (CLAUDE_MODELS.some((model) => model.id === trimmed)) {
return trimmed;
}
// Match single-segment major-version model names such as claude-fable-5
// and claude-sonnet-5, including dated runtime strings from the SDK.
const singleSegmentMatch = trimmed.match(/(?:claude-)?(fable|opus|sonnet|haiku)[-_ ]+(\d+)/i);
if (singleSegmentMatch) {
const family = singleSegmentMatch[1].toLowerCase();
const major = singleSegmentMatch[2];
const suffix = trimmed.toLowerCase().includes("[1m]") ? "[1m]" : "";
const candidates = [`claude-${family}-${major}${suffix}`, `claude-${family}-${major}`];
for (const candidate of candidates) {
if (CLAUDE_MODELS.some((model) => model.id === candidate)) {
return candidate;
}
}
}
// Match: claude-{family}-{major}-{minor}[1m]? possibly followed by a date suffix
const runtimeMatch = trimmed.match(
/(?:claude-)?(opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?/i,
);
if (!runtimeMatch) {
return null;
}
const family = runtimeMatch[1].toLowerCase();
const major = runtimeMatch[2];
const minor = runtimeMatch[3];
const suffix = runtimeMatch[4] ?? "";
return `claude-${family}-${major}-${minor}${suffix}`;
return normalizeClaudeManifestRuntimeModelId(value);
}
@@ -0,0 +1,30 @@
import type { Logger } from "pino";
import { GenericACPAgentClient } from "./generic-acp-agent.js";
interface TraeACPAgentClientOptions {
logger: Logger;
command: [string, ...string[]];
env?: Record<string, string>;
providerId?: string;
label?: string;
providerParams?: unknown;
}
const TRAE_INITIAL_COMMANDS_WAIT_TIMEOUT_MS = 10_000;
export class TraeACPAgentClient extends GenericACPAgentClient {
constructor(options: TraeACPAgentClientOptions) {
super({
logger: options.logger,
command: options.command,
env: options.env,
providerId: options.providerId,
label: options.label,
providerParams: options.providerParams,
// traecli publishes slash commands and skills asynchronously via available_commands_update.
waitForInitialCommands: true,
initialCommandsWaitTimeoutMs: TRAE_INITIAL_COMMANDS_WAIT_TIMEOUT_MS,
});
}
}
@@ -29,6 +29,7 @@ import {
import { WaitForAgentTracker } from "../wait-for-agent-tracker.js";
import { createAgentCommand, type CreateAgentFromMcpInput } from "../create-agent/create.js";
import type { VoiceCallerContext, VoiceSpeakHandler } from "../../voice-types.js";
import type { FirstAgentContext } from "../../messages.js";
import { expandUserPath, isSameOrDescendantPath, resolvePathFromBase } from "../../path-utils.js";
import type { TerminalManager } from "../../../terminal/terminal-manager.js";
import type { CreatePaseoWorktreeWorkflowFn } from "../../worktree-session.js";
@@ -65,6 +66,7 @@ import {
} from "../lifecycle-command.js";
import type { GitHubService } from "../../../services/github-service.js";
import type { WorkspaceGitService } from "../../workspace-git-service.js";
import type { WorkspaceRegistry } from "../../workspace-registry.js";
import { WorktreeRequestError } from "../../worktree-errors.js";
import {
archiveCommand,
@@ -99,11 +101,16 @@ export interface PaseoToolHostDependencies {
listActiveWorkspaces?: ArchiveDependencies["listActiveWorkspaces"];
archiveWorkspaceRecord?: ArchiveDependencies["archiveWorkspaceRecord"];
emitWorkspaceUpdatesForWorkspaceIds?: ArchiveDependencies["emitWorkspaceUpdatesForWorkspaceIds"];
workspaceRegistry?: Pick<WorkspaceRegistry, "get" | "upsert">;
markWorkspaceArchiving?: ArchiveDependencies["markWorkspaceArchiving"];
clearWorkspaceArchiving?: ArchiveDependencies["clearWorkspaceArchiving"];
createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn;
// Mints a fresh directory workspace for a cwd and returns its id.
ensureWorkspaceForCreate?: (cwd: string) => Promise<string>;
ensureWorkspaceForCreate?: (
cwd: string,
firstAgentContext?: FirstAgentContext,
) => Promise<string>;
browserToolsEnabled?: boolean;
browserToolsBroker?: BrowserToolsBroker | null;
paseoHome?: string;
worktreesRoot?: string;
@@ -554,6 +561,22 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
return options.ensureWorkspaceForCreate(resolvedCwd);
}
function resolveWorkspaceIdForRename(requestedWorkspaceId?: string): string {
const explicitWorkspaceId = requestedWorkspaceId?.trim();
if (explicitWorkspaceId) {
return explicitWorkspaceId;
}
if (callerAgentId) {
const callerAgent = resolveCallerAgent();
if (!callerAgent?.workspaceId) {
throw new Error(`Caller agent ${callerAgentId} has no current workspace`);
}
return callerAgent.workspaceId;
}
throw new Error("workspaceId is required outside an agent-scoped session");
}
const buildCallerAgentScheduleConfigExtras = (
callerAgent: NonNullable<ReturnType<typeof resolveCallerAgent>>,
): Record<string, unknown> => {
@@ -1005,7 +1028,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
return toCatalog();
}
if (options.browserToolsBroker) {
if (options.browserToolsEnabled && options.browserToolsBroker) {
registerBrowserTools({
registerTool,
broker: options.browserToolsBroker,
@@ -1163,7 +1186,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
async function resolveCreateAgentToolArgs(args: unknown): Promise<ResolvedCreateAgentToolArgs> {
if (callerAgentId) {
const parsed = agentToAgentCreateAgentArgsSchema.parse(args);
const { cwd, workspaceId, worktree } = await resolveCreateAgentWorkspace(parsed.workspace);
const { cwd, workspaceId, worktree } = await resolveCreateAgentWorkspace(parsed.workspace, {
prompt: parsed.initialPrompt,
});
return {
kind: "agent-scoped",
parsedArgs: parsed,
@@ -1177,7 +1202,9 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
if (parsedArgs.relationship.kind === "subagent") {
throw new Error("relationship subagent requires an agent-scoped tool session");
}
const { cwd, workspaceId, worktree } = await resolveCreateAgentWorkspace(parsedArgs.workspace);
const { cwd, workspaceId, worktree } = await resolveCreateAgentWorkspace(parsedArgs.workspace, {
prompt: parsedArgs.initialPrompt,
});
return {
kind: "top-level",
parsedArgs,
@@ -1291,6 +1318,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
async function resolveCreateAgentWorkspace(
workspace: AgentToAgentCreateAgentArgs["workspace"] | TopLevelCreateAgentArgs["workspace"],
firstAgentContext: FirstAgentContext | undefined,
): Promise<{
cwd: string | undefined;
workspaceId: string | undefined;
@@ -1342,7 +1370,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
}
return {
cwd,
workspaceId: await options.ensureWorkspaceForCreate(cwd),
workspaceId: await options.ensureWorkspaceForCreate(cwd, firstAgentContext),
worktree: undefined,
};
}
@@ -1781,6 +1809,66 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
},
);
registerTool(
"rename_workspace",
{
title: "Rename workspace",
description:
"Rename a workspace by setting its user-visible title. Omit workspaceId to rename your current workspace.",
inputSchema: {
workspaceId: z
.string()
.trim()
.min(1)
.optional()
.describe("Workspace id to rename. Omit to rename your current workspace."),
title: z
.string()
.trim()
.min(1, "title is required")
.describe("New user-visible workspace title."),
},
outputSchema: {
success: z.boolean(),
workspaceId: z.string(),
title: z.string(),
},
},
async ({ workspaceId: requestedWorkspaceId, title }) => {
if (!options.workspaceRegistry) {
throw new Error("Workspace registry is required to rename workspaces");
}
if (!options.emitWorkspaceUpdatesForWorkspaceIds) {
throw new Error("Workspace update emitter is required to rename workspaces");
}
const workspaceId = resolveWorkspaceIdForRename(requestedWorkspaceId);
const existing = await options.workspaceRegistry.get(workspaceId);
if (!existing) {
throw new Error(`Workspace ${workspaceId} not found`);
}
if (existing.archivedAt) {
throw new Error(`Workspace ${workspaceId} is archived`);
}
await options.workspaceRegistry.upsert({
...existing,
title,
updatedAt: new Date().toISOString(),
});
await options.emitWorkspaceUpdatesForWorkspaceIds([workspaceId]);
return {
content: [],
structuredContent: ensureValidJson({
success: true,
workspaceId,
title,
}),
};
},
);
registerTool(
"list_terminals",
{
+44 -6
View File
@@ -139,7 +139,7 @@ import type { PushNotificationSender } from "./push/notifications.js";
import { getOrCreateServerId } from "./server-id.js";
import { resolveDaemonVersion } from "./daemon-version.js";
import type { AgentClient, AgentProvider } from "./agent/agent-sdk-types.js";
import type { TerminalProfile } from "@getpaseo/protocol/messages";
import type { FirstAgentContext, TerminalProfile } from "@getpaseo/protocol/messages";
import type {
AgentProviderRuntimeSettingsMap,
ProviderOverride,
@@ -162,6 +162,10 @@ import {
type DaemonAuthConfig,
} from "./auth.js";
import { createWebUiMiddleware } from "./web-ui.js";
import { WorkspaceAutoName } from "./workspace-auto-name.js";
import { createGitMutationService } from "./session/git-mutation/git-mutation-service.js";
import { workspaceIdsOnCheckout } from "./workspace-directory.js";
import { resolveFirstAgentPromptTitle } from "./agent/create-agent-title.js";
const MAX_MCP_DEBUG_BATCH_ITEMS = 10;
const REDACTED_LOG_VALUE = "[redacted]";
@@ -480,9 +484,7 @@ export async function createPaseoDaemon(
logger,
);
const browserToolsPolicy = new DaemonConfigBrowserToolsPolicy(daemonConfigStore);
const browserToolsBroker = new BrowserToolsBroker({
policy: browserToolsPolicy,
});
const browserToolsBroker = new BrowserToolsBroker({});
const serverId = getOrCreateServerId(config.paseoHome, { logger });
const daemonKeyPair = await loadOrCreateDaemonKeyPair(config.paseoHome, logger);
@@ -854,11 +856,21 @@ export async function createPaseoDaemon(
const findWorkspaceIdForCwdExternal = async (cwd: string): Promise<string | null> => {
return resolveWorkspaceIdForPath(cwd, await workspaceRegistry.list());
};
const ensureWorkspaceForCreateExternal = async (cwd: string): Promise<string> => {
const ensureWorkspaceForCreateExternal = async (
cwd: string,
firstAgentContext?: FirstAgentContext,
): Promise<string> => {
const workspace = await createLocalCheckoutWorkspace(
{ cwd },
{ cwd, title: resolveFirstAgentPromptTitle(firstAgentContext) },
{ projectRegistry, workspaceRegistry, workspaceGitService },
);
if (firstAgentContext) {
workspaceAutoName.scheduleForDirectory({
workspaceId: workspace.workspaceId,
cwd: workspace.cwd,
firstAgentContext,
});
}
return workspace.workspaceId;
};
const listActiveWorkspacesExternal = async (): Promise<ActiveWorkspaceRef[]> => {
@@ -891,9 +903,30 @@ export async function createPaseoDaemon(
),
);
};
const emitWorkspaceUpdateForCwdExternal = async (cwd: string) => {
const workspaceIds = workspaceIdsOnCheckout(await workspaceRegistry.list(), cwd);
await emitWorkspaceUpdatesExternal(workspaceIds);
};
const emitExternalSessionMessage = (message: SessionOutboundMessage) => {
wsServer?.broadcast(wrapSessionMessage(message));
};
const workspaceAutoName = new WorkspaceAutoName({
agentManager,
workspaceRegistry,
workspaceGitService,
providerSnapshotManager,
readDaemonConfig: () => ({ metadataGeneration: daemonConfigStore.get().metadataGeneration }),
gitMutation: createGitMutationService({
workspaceGitService,
github,
logger,
}),
emitWorkspaceUpdateForCwd: emitWorkspaceUpdateForCwdExternal,
emitWorkspaceUpdateForWorkspaceId: async (workspaceId) => {
await emitWorkspaceUpdatesExternal([workspaceId]);
},
logger,
});
setupAutoArchiveOnMerge({
paseoHome: config.paseoHome,
@@ -941,6 +974,8 @@ export async function createPaseoDaemon(
.map((session) => session.warmWorkspaceGitDataForWorkspace(workspace)) ?? [],
);
},
autoNameWorkspaceBranchForFirstAgent: (autoNameInput) =>
workspaceAutoName.scheduleForWorktree(autoNameInput),
emitWorkspaceUpdateForWorkspaceId: async (workspaceId) => {
await emitWorkspaceUpdatesExternal([workspaceId]);
},
@@ -976,10 +1011,12 @@ export async function createPaseoDaemon(
listActiveWorkspaces: listActiveWorkspacesExternal,
archiveWorkspaceRecord: archiveWorkspaceRecordExternal,
emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal,
workspaceRegistry,
markWorkspaceArchiving: markWorkspaceArchivingExternal,
clearWorkspaceArchiving: clearWorkspaceArchivingExternal,
ensureWorkspaceForCreate: ensureWorkspaceForCreateExternal,
createPaseoWorktree: createPaseoWorktreeForTools,
browserToolsEnabled: browserToolsPolicy.isEnabled(),
browserToolsBroker,
paseoHome: config.paseoHome,
worktreesRoot: config.worktreesRoot,
@@ -1207,6 +1244,7 @@ export async function createPaseoDaemon(
daemonConfigStore,
mcpBaseUrl,
{ allowedOrigins, hostnames: configuredHostnames },
workspaceAutoName,
config.auth,
speechService,
terminalManager,
@@ -1,18 +1,31 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import type {
BrowserAutomationCommand,
BrowserAutomationCommandName,
BrowserAutomationExecuteRequest,
BrowserAutomationExecuteResponse,
} from "@getpaseo/protocol/browser-automation/rpc-schemas";
import { BrowserToolsBroker, type BrowserToolsDesktopClient } from "./broker.js";
import { StaticBrowserToolsPolicy } from "./policy.js";
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas";
import { BrowserToolsBroker, type BrowserHostClient } from "./broker.js";
const BROWSER_ID = "11111111-1111-4111-8111-111111111111";
const SECOND_BROWSER_ID = "22222222-2222-4222-8222-222222222222";
class FakeDesktopClient implements BrowserToolsDesktopClient {
class FakeBrowserHostClient implements BrowserHostClient {
public readonly receivedRequests: BrowserAutomationExecuteRequest[] = [];
public readonly hostKind: string;
public readonly supportedCommands: readonly BrowserAutomationCommandName[];
public constructor(public readonly id: string) {}
public constructor(
public readonly id: string,
options: {
hostKind?: string;
supportedCommands?: readonly BrowserAutomationCommandName[];
} = {},
) {
this.hostKind = options.hostKind ?? "desktop app";
this.supportedCommands = options.supportedCommands ?? [...BROWSER_AUTOMATION_COMMAND_NAMES];
}
public sendBrowserAutomationRequest(request: BrowserAutomationExecuteRequest): void {
this.receivedRequests.push(request);
@@ -21,25 +34,38 @@ class FakeDesktopClient implements BrowserToolsDesktopClient {
public resolveLatestWith(
broker: BrowserToolsBroker,
responsePayload: BrowserAutomationExecuteResponse["payload"],
): boolean {
const latest = this.receivedRequests.at(-1);
if (!latest) {
throw new Error(`Host ${this.id} has not received a browser request.`);
}
return this.resolveRequestWith(broker, latest, responsePayload);
}
public resolveRequestWith(
broker: BrowserToolsBroker,
request: BrowserAutomationExecuteRequest,
responsePayload: BrowserAutomationExecuteResponse["payload"],
): boolean {
return broker.receiveResponse({
type: "browser.automation.execute.response",
payload: responsePayload,
payload: { ...responsePayload, requestId: request.requestId },
});
}
}
class FailingDesktopClient implements BrowserToolsDesktopClient {
public readonly id = "desktop-1";
class FailingBrowserHostClient implements BrowserHostClient {
public readonly id = "host-1";
public readonly hostKind = "desktop app";
public readonly supportedCommands = [...BROWSER_AUTOMATION_COMMAND_NAMES];
public sendBrowserAutomationRequest(): void {
throw new Error("websocket send failed");
}
}
function createBroker(options: { enabled: boolean; timeoutMs?: number }): BrowserToolsBroker {
function createBroker(options: { timeoutMs?: number } = {}): BrowserToolsBroker {
return new BrowserToolsBroker({
policy: new StaticBrowserToolsPolicy(options.enabled),
defaultTimeoutMs: options.timeoutMs ?? 100,
createRequestId: () => "req-1",
});
@@ -54,37 +80,23 @@ describe("BrowserToolsBroker", () => {
vi.useRealTimers();
});
test("disabled returns browser_disabled", async () => {
const broker = createBroker({ enabled: false });
test("no connected browser host returns a retryable browser_no_host error", async () => {
const broker = createBroker();
await expect(broker.execute({ command: snapshotCommand() })).resolves.toEqual({
requestId: "req-1",
ok: false,
error: {
code: "browser_disabled",
message: "Browser tools are disabled. Enable daemon.browserTools.enabled to use them.",
retryable: false,
},
});
});
test("no capable desktop returns browser_no_desktop", async () => {
const broker = createBroker({ enabled: true });
await expect(broker.execute({ command: snapshotCommand() })).resolves.toEqual({
requestId: "req-1",
ok: false,
error: {
code: "browser_no_desktop",
message: "No desktop browser automation client is connected.",
code: "browser_no_host",
message: "No browser automation host is connected.",
retryable: true,
},
});
});
test("invalid browser requests return structured failures without contacting desktop", async () => {
const broker = createBroker({ enabled: true });
const client = new FakeDesktopClient("desktop-1");
test("invalid browser requests return structured failures without contacting a host", async () => {
const broker = createBroker();
const client = new FakeBrowserHostClient("host-1");
broker.registerClient(client);
await expect(
@@ -107,9 +119,9 @@ describe("BrowserToolsBroker", () => {
expect(broker.getPendingRequestCount()).toBe(0);
});
test("capable fake desktop receives request and returns response", async () => {
const broker = createBroker({ enabled: true });
const client = new FakeDesktopClient("desktop-1");
test("capable browser host receives request and returns response", async () => {
const broker = createBroker();
const client = new FakeBrowserHostClient("host-1");
broker.registerClient(client);
const resultPromise = broker.execute({
@@ -165,9 +177,9 @@ describe("BrowserToolsBroker", () => {
expect(broker.getPendingRequestCount()).toBe(0);
});
test("desktop receives snapshot requests", async () => {
const broker = createBroker({ enabled: true });
const client = new FakeDesktopClient("desktop-1");
test("single browser host receives snapshot requests", async () => {
const broker = createBroker();
const client = new FakeBrowserHostClient("host-1");
broker.registerClient(client);
const resultPromise = broker.execute({
@@ -193,7 +205,10 @@ describe("BrowserToolsBroker", () => {
workspaceId: "workspace-1",
url: "https://example.com",
title: "Example",
elements: [],
format: "aria-yaml",
snapshot: "- document",
truncated: false,
stats: { nodeCount: 1, refCount: 0, textLength: 10 },
},
});
@@ -206,15 +221,574 @@ describe("BrowserToolsBroker", () => {
workspaceId: "workspace-1",
url: "https://example.com",
title: "Example",
elements: [],
format: "aria-yaml",
snapshot: "- document",
truncated: false,
stats: { nodeCount: 1, refCount: 0, textLength: 10 },
},
});
});
test("new tabs target the most recently registered host and tab commands stay with that host", async () => {
const broker = createBroker();
const firstHost = new FakeBrowserHostClient("host-1");
const recentHost = new FakeBrowserHostClient("host-2");
broker.registerClient(firstHost);
broker.registerClient(recentHost);
const newTabPromise = broker.execute({
command: { command: "new_tab", args: { url: "https://example.com" } },
workspaceId: "workspace-1",
});
expect(firstHost.receivedRequests).toEqual([]);
expect(recentHost.receivedRequests).toEqual([
{
type: "browser.automation.execute.request",
requestId: "req-1",
workspaceId: "workspace-1",
command: { command: "new_tab", args: { url: "https://example.com" } },
},
]);
recentHost.resolveLatestWith(broker, {
requestId: "req-1",
ok: true,
result: {
command: "new_tab",
browserId: BROWSER_ID,
workspaceId: "workspace-1",
url: "https://example.com",
},
});
await expect(newTabPromise).resolves.toMatchObject({
ok: true,
result: { command: "new_tab", browserId: BROWSER_ID },
});
const snapshotPromise = broker.execute({
command: { command: "snapshot", args: { browserId: BROWSER_ID } },
workspaceId: "workspace-1",
});
expect(firstHost.receivedRequests).toEqual([]);
expect(recentHost.receivedRequests.at(-1)).toEqual({
type: "browser.automation.execute.request",
requestId: "req-1",
workspaceId: "workspace-1",
command: { command: "snapshot", args: { browserId: BROWSER_ID } },
});
recentHost.resolveLatestWith(broker, {
requestId: "req-1",
ok: true,
result: {
command: "snapshot",
browserId: BROWSER_ID,
workspaceId: "workspace-1",
url: "https://example.com",
title: "Example",
format: "aria-yaml",
snapshot: "- document",
truncated: false,
stats: { nodeCount: 1, refCount: 0, textLength: 10 },
},
});
await expect(snapshotPromise).resolves.toMatchObject({
ok: true,
result: { command: "snapshot", browserId: BROWSER_ID },
});
});
test("list tabs aggregates all hosts and seeds browser id affinity", async () => {
const broker = createBroker();
const firstHost = new FakeBrowserHostClient("host-1");
const secondHost = new FakeBrowserHostClient("host-2");
broker.registerClient(firstHost);
broker.registerClient(secondHost);
const listPromise = broker.execute({
command: { command: "list_tabs", args: {} },
workspaceId: "workspace-1",
});
expect(firstHost.receivedRequests).toEqual([
{
type: "browser.automation.execute.request",
requestId: "req-1:host-1",
workspaceId: "workspace-1",
command: { command: "list_tabs", args: {} },
},
]);
expect(secondHost.receivedRequests).toEqual([
{
type: "browser.automation.execute.request",
requestId: "req-1:host-2",
workspaceId: "workspace-1",
command: { command: "list_tabs", args: {} },
},
]);
firstHost.resolveLatestWith(broker, {
requestId: "req-1:host-1",
ok: true,
result: {
command: "list_tabs",
tabs: [
{
browserId: BROWSER_ID,
workspaceId: "workspace-1",
url: "https://one.example",
title: "One",
},
],
},
});
secondHost.resolveLatestWith(broker, {
requestId: "req-1:host-2",
ok: true,
result: {
command: "list_tabs",
tabs: [
{
browserId: SECOND_BROWSER_ID,
workspaceId: "workspace-1",
url: "https://two.example",
title: "Two",
},
],
},
});
await expect(listPromise).resolves.toEqual({
requestId: "req-1",
ok: true,
result: {
command: "list_tabs",
tabs: [
{
browserId: BROWSER_ID,
workspaceId: "workspace-1",
url: "https://one.example",
title: "One",
isActive: false,
isLoading: false,
},
{
browserId: SECOND_BROWSER_ID,
workspaceId: "workspace-1",
url: "https://two.example",
title: "Two",
isActive: false,
isLoading: false,
},
],
},
});
const firstSnapshot = broker.execute({
command: { command: "snapshot", args: { browserId: BROWSER_ID } },
workspaceId: "workspace-1",
});
const secondSnapshot = broker.execute({
command: { command: "snapshot", args: { browserId: SECOND_BROWSER_ID } },
workspaceId: "workspace-1",
requestId: "req-2",
});
expect(firstHost.receivedRequests.at(-1)?.command).toEqual({
command: "snapshot",
args: { browserId: BROWSER_ID },
});
expect(secondHost.receivedRequests.at(-1)?.command).toEqual({
command: "snapshot",
args: { browserId: SECOND_BROWSER_ID },
});
firstHost.resolveLatestWith(broker, {
requestId: "req-1",
ok: true,
result: {
command: "snapshot",
browserId: BROWSER_ID,
workspaceId: "workspace-1",
url: "https://one.example",
title: "One",
format: "aria-yaml",
snapshot: "- document",
truncated: false,
stats: { nodeCount: 1, refCount: 0, textLength: 10 },
},
});
secondHost.resolveLatestWith(broker, {
requestId: "req-2",
ok: true,
result: {
command: "snapshot",
browserId: SECOND_BROWSER_ID,
workspaceId: "workspace-1",
url: "https://two.example",
title: "Two",
format: "aria-yaml",
snapshot: "- document",
truncated: false,
stats: { nodeCount: 1, refCount: 0, textLength: 10 },
},
});
await expect(firstSnapshot).resolves.toMatchObject({
ok: true,
result: { command: "snapshot", browserId: BROWSER_ID },
});
await expect(secondSnapshot).resolves.toMatchObject({
ok: true,
result: { command: "snapshot", browserId: SECOND_BROWSER_ID },
});
});
test.each([
{
name: "scroll",
command: { command: "scroll", args: { browserId: BROWSER_ID, deltaX: 0, deltaY: 400 } },
result: { command: "scroll", browserId: BROWSER_ID, deltaX: 0, deltaY: 400 },
},
{
name: "resize",
command: { command: "resize", args: { browserId: BROWSER_ID, width: 1024, height: 768 } },
result: { command: "resize", browserId: BROWSER_ID, width: 1024, height: 768 },
},
{
name: "close_tab",
command: { command: "close_tab", args: { browserId: BROWSER_ID } },
result: { command: "close_tab", browserId: BROWSER_ID },
},
] satisfies Array<{
name: string;
command: BrowserAutomationCommand;
result: BrowserAutomationExecuteResponse["payload"] extends infer Payload
? Payload extends { ok: true }
? Payload["result"]
: never
: never;
}>)("routes $name to the host that owns the browser id", async ({ command, result }) => {
const broker = createBroker();
const other = new FakeBrowserHostClient("host-1");
const owner = new FakeBrowserHostClient("host-2");
broker.registerClient(other);
broker.registerClient(owner);
const newTabPromise = broker.execute({ command: { command: "new_tab", args: {} } });
owner.resolveLatestWith(broker, {
requestId: "req-1",
ok: true,
result: {
command: "new_tab",
browserId: BROWSER_ID,
workspaceId: "workspace-1",
url: "https://one.example",
},
});
await newTabPromise;
const resultPromise = broker.execute({ command, requestId: "req-command" });
expect(owner.receivedRequests.at(-1)).toEqual({
type: "browser.automation.execute.request",
requestId: "req-command",
command,
});
expect(other.receivedRequests).toEqual([]);
owner.resolveLatestWith(broker, {
requestId: "req-command",
ok: true,
result,
});
await expect(resultPromise).resolves.toEqual({
requestId: "req-command",
ok: true,
result,
});
});
test("successful close_tab clears browser id host affinity", async () => {
const broker = createBroker();
const other = new FakeBrowserHostClient("host-1");
const owner = new FakeBrowserHostClient("host-2");
broker.registerClient(other);
broker.registerClient(owner);
const newTabPromise = broker.execute({ command: { command: "new_tab", args: {} } });
owner.resolveLatestWith(broker, {
requestId: "req-1",
ok: true,
result: {
command: "new_tab",
browserId: BROWSER_ID,
workspaceId: "workspace-1",
url: "https://one.example",
},
});
await newTabPromise;
const closePromise = broker.execute({
command: { command: "close_tab", args: { browserId: BROWSER_ID } },
requestId: "req-close",
});
owner.resolveLatestWith(broker, {
requestId: "req-close",
ok: true,
result: { command: "close_tab", browserId: BROWSER_ID },
});
await expect(closePromise).resolves.toMatchObject({
ok: true,
result: { command: "close_tab", browserId: BROWSER_ID },
});
await expect(
broker.execute({
command: { command: "snapshot", args: { browserId: BROWSER_ID } },
requestId: "req-after-close",
}),
).resolves.toEqual({
requestId: "req-after-close",
ok: false,
error: {
code: "browser_tab_not_found",
message: `Browser tab ${BROWSER_ID} is not associated with a connected browser automation host. Call browser_list_tabs and use one of the returned browserId values.`,
retryable: false,
},
});
expect(owner.receivedRequests.at(-1)?.command).toEqual({
command: "close_tab",
args: { browserId: BROWSER_ID },
});
expect(other.receivedRequests).toEqual([]);
});
test("failed list tabs aggregation does not seed browser id affinity", async () => {
const broker = createBroker();
const firstHost = new FakeBrowserHostClient("host-1");
const secondHost = new FakeBrowserHostClient("host-2");
broker.registerClient(firstHost);
broker.registerClient(secondHost);
const listPromise = broker.execute({
command: { command: "list_tabs", args: {} },
workspaceId: "workspace-1",
});
firstHost.resolveLatestWith(broker, {
requestId: "req-1:host-1",
ok: true,
result: {
command: "list_tabs",
tabs: [
{
browserId: BROWSER_ID,
workspaceId: "workspace-1",
url: "https://one.example",
title: "One",
},
],
},
});
secondHost.resolveLatestWith(broker, {
requestId: "req-1:host-2",
ok: false,
error: {
code: "browser_timeout",
message: "Host did not answer list_tabs.",
retryable: true,
},
});
await expect(listPromise).resolves.toEqual({
requestId: "req-1",
ok: false,
error: {
code: "browser_timeout",
message: "Host did not answer list_tabs.",
retryable: true,
},
});
await expect(
broker.execute({
command: { command: "snapshot", args: { browserId: BROWSER_ID } },
workspaceId: "workspace-1",
}),
).resolves.toEqual({
requestId: "req-1",
ok: false,
error: {
code: "browser_tab_not_found",
message: `Browser tab ${BROWSER_ID} is not associated with a connected browser automation host. Call browser_list_tabs and use one of the returned browserId values.`,
retryable: false,
},
});
expect(firstHost.receivedRequests).toHaveLength(1);
expect(secondHost.receivedRequests).toHaveLength(1);
});
test("unsupported commands are rejected before sending to the routed host", async () => {
const broker = createBroker();
const client = new FakeBrowserHostClient("host-1", {
supportedCommands: ["list_tabs"],
hostKind: "desktop app",
});
broker.registerClient(client);
await expect(broker.execute({ command: snapshotCommand() })).resolves.toEqual({
requestId: "req-1",
ok: false,
error: {
code: "browser_unsupported",
message: 'Browser automation command "snapshot" is not supported by the desktop app.',
retryable: false,
},
});
await expect(
broker.execute({
command: {
command: "evaluate",
args: { browserId: BROWSER_ID, function: "() => document.title" },
},
}),
).resolves.toEqual({
requestId: "req-1",
ok: false,
error: {
code: "browser_unsupported",
message: 'Browser automation command "evaluate" is not supported by the desktop app.',
retryable: false,
},
});
await expect(
broker.execute({
command: { command: "scroll", args: { browserId: BROWSER_ID, deltaX: 0, deltaY: 400 } },
}),
).resolves.toEqual({
requestId: "req-1",
ok: false,
error: {
code: "browser_unsupported",
message: 'Browser automation command "scroll" is not supported by the desktop app.',
retryable: false,
},
});
expect(client.receivedRequests).toEqual([]);
});
test("unregistering a host strands its browser ids instead of routing them to another host", async () => {
const broker = createBroker();
const owner = new FakeBrowserHostClient("host-1");
const unregisterOwner = broker.registerClient(owner);
const newTabPromise = broker.execute({
command: { command: "new_tab", args: {} },
workspaceId: "workspace-1",
});
owner.resolveLatestWith(broker, {
requestId: "req-1",
ok: true,
result: {
command: "new_tab",
browserId: BROWSER_ID,
workspaceId: "workspace-1",
url: "https://example.com",
},
});
await newTabPromise;
unregisterOwner();
const replacement = new FakeBrowserHostClient("host-2");
broker.registerClient(replacement);
await expect(
broker.execute({
command: { command: "snapshot", args: { browserId: BROWSER_ID } },
workspaceId: "workspace-1",
}),
).resolves.toEqual({
requestId: "req-1",
ok: false,
error: {
code: "browser_no_host",
message: `The app hosting browser tab ${BROWSER_ID} disconnected.`,
retryable: true,
},
});
expect(replacement.receivedRequests).toEqual([]);
});
test("reconnecting the same host reclaims its stranded browser ids", async () => {
const broker = createBroker();
const owner = new FakeBrowserHostClient("host-1");
const unregisterOwner = broker.registerClient(owner);
const newTabPromise = broker.execute({
command: { command: "new_tab", args: {} },
workspaceId: "workspace-1",
});
owner.resolveLatestWith(broker, {
requestId: "req-1",
ok: true,
result: {
command: "new_tab",
browserId: BROWSER_ID,
workspaceId: "workspace-1",
url: "https://example.com",
},
});
await newTabPromise;
unregisterOwner();
const reconnectedOwner = new FakeBrowserHostClient("host-1");
broker.registerClient(reconnectedOwner);
const snapshotPromise = broker.execute({
command: { command: "snapshot", args: { browserId: BROWSER_ID } },
workspaceId: "workspace-1",
});
expect(reconnectedOwner.receivedRequests).toEqual([
{
type: "browser.automation.execute.request",
requestId: "req-1",
workspaceId: "workspace-1",
command: { command: "snapshot", args: { browserId: BROWSER_ID } },
},
]);
reconnectedOwner.resolveLatestWith(broker, {
requestId: "req-1",
ok: true,
result: {
command: "snapshot",
browserId: BROWSER_ID,
workspaceId: "workspace-1",
url: "https://example.com",
title: "Example",
format: "aria-yaml",
snapshot: "- document",
truncated: false,
stats: { nodeCount: 1, refCount: 0, textLength: 10 },
},
});
await expect(snapshotPromise).resolves.toMatchObject({
ok: true,
result: { command: "snapshot", browserId: BROWSER_ID },
});
});
test("timeout resolves browser_timeout and clears pending state", async () => {
vi.useFakeTimers();
const broker = createBroker({ enabled: true, timeoutMs: 50 });
broker.registerClient(new FakeDesktopClient("desktop-1"));
const broker = createBroker({ timeoutMs: 50 });
broker.registerClient(new FakeBrowserHostClient("host-1"));
const resultPromise = broker.execute({ command: snapshotCommand() });
expect(broker.getPendingRequestCount()).toBe(1);
@@ -234,8 +808,8 @@ describe("BrowserToolsBroker", () => {
});
test("disconnect resolves retryable failure and clears pending request", async () => {
const broker = createBroker({ enabled: true });
const client = new FakeDesktopClient("desktop-1");
const broker = createBroker();
const client = new FakeBrowserHostClient("host-1");
const unregister = broker.registerClient(client);
const resultPromise = broker.execute({ command: snapshotCommand() });
@@ -247,17 +821,55 @@ describe("BrowserToolsBroker", () => {
requestId: "req-1",
ok: false,
error: {
code: "browser_no_desktop",
message: "The desktop browser automation client disconnected before responding.",
code: "browser_no_host",
message: "The browser automation host disconnected before responding.",
retryable: true,
},
});
expect(broker.getPendingRequestCount()).toBe(0);
});
test("desktop send failure resolves structured failure and clears pending request", async () => {
const broker = createBroker({ enabled: true });
broker.registerClient(new FailingDesktopClient());
test("replacing a host registration resolves pending requests from the old registration", async () => {
const broker = createBroker();
const oldHost = new FakeBrowserHostClient("host-1");
const newHost = new FakeBrowserHostClient("host-1");
broker.registerClient(oldHost);
const pendingResult = broker.execute({ command: snapshotCommand() });
expect(oldHost.receivedRequests).toHaveLength(1);
expect(broker.getPendingRequestCount()).toBe(1);
broker.registerClient(newHost);
await expect(pendingResult).resolves.toEqual({
requestId: "req-1",
ok: false,
error: {
code: "browser_no_host",
message: "The browser automation host disconnected before responding.",
retryable: true,
},
});
expect(broker.getPendingRequestCount()).toBe(0);
const listResult = broker.execute({ command: { command: "list_tabs", args: {} } });
expect(newHost.receivedRequests).toHaveLength(1);
newHost.resolveLatestWith(broker, {
requestId: "req-1",
ok: true,
result: { command: "list_tabs", tabs: [] },
});
await expect(listResult).resolves.toEqual({
requestId: "req-1",
ok: true,
result: { command: "list_tabs", tabs: [] },
});
});
test("browser host send failure resolves structured failure and clears pending request", async () => {
const broker = createBroker();
broker.registerClient(new FailingBrowserHostClient());
await expect(broker.execute({ command: snapshotCommand() })).resolves.toEqual({
requestId: "req-1",
@@ -272,8 +884,8 @@ describe("BrowserToolsBroker", () => {
});
test("explicit browser failure response propagates typed error", async () => {
const broker = createBroker({ enabled: true });
const client = new FakeDesktopClient("desktop-1");
const broker = createBroker();
const client = new FakeBrowserHostClient("host-1");
broker.registerClient(client);
const resultPromise = broker.execute({ command: snapshotCommand() });
@@ -301,8 +913,8 @@ describe("BrowserToolsBroker", () => {
});
test("invalid browser response resolves a structured failure and clears pending state", async () => {
const broker = createBroker({ enabled: true });
const client = new FakeDesktopClient("desktop-1");
const broker = createBroker();
const client = new FakeBrowserHostClient("host-1");
broker.registerClient(client);
const resultPromise = broker.execute({ command: snapshotCommand() });
@@ -3,14 +3,16 @@ import {
BrowserAutomationExecuteRequestSchema,
BrowserAutomationExecuteResponseSchema,
type BrowserAutomationCommand,
type BrowserAutomationCommandName,
type BrowserAutomationExecuteRequest,
type BrowserAutomationExecuteResponse,
} from "@getpaseo/protocol/browser-automation/rpc-schemas";
import { browserToolsFailure, type BrowserToolsResponsePayload } from "./errors.js";
import type { BrowserToolsPolicy } from "./policy.js";
export interface BrowserToolsDesktopClient {
export interface BrowserHostClient {
id: string;
hostKind: string;
supportedCommands: readonly BrowserAutomationCommandName[];
sendBrowserAutomationRequest(request: BrowserAutomationExecuteRequest): void | Promise<void>;
}
@@ -25,12 +27,18 @@ export interface BrowserToolsExecuteInput {
interface PendingBrowserToolsRequest {
clientId: string;
rememberAffinity: boolean;
timeout: ReturnType<typeof setTimeout>;
resolve: (payload: BrowserToolsResponsePayload) => void;
}
interface RegisteredBrowserHost {
client: BrowserHostClient;
registeredAt: number;
supportedCommands: ReadonlySet<BrowserAutomationCommandName>;
}
export interface BrowserToolsBrokerOptions {
policy: BrowserToolsPolicy;
defaultTimeoutMs?: number;
createRequestId?: () => string;
}
@@ -38,28 +46,44 @@ export interface BrowserToolsBrokerOptions {
const DEFAULT_BROWSER_TOOLS_TIMEOUT_MS = 15_000;
export class BrowserToolsBroker {
private readonly policy: BrowserToolsPolicy;
private readonly defaultTimeoutMs: number;
private readonly createRequestId: () => string;
private readonly clients = new Map<string, BrowserToolsDesktopClient>();
private readonly clients = new Map<string, RegisteredBrowserHost>();
private readonly pending = new Map<string, PendingBrowserToolsRequest>();
private readonly browserHostByBrowserId = new Map<string, string>();
private readonly strandedBrowserHostByBrowserId = new Map<string, string>();
private registrationSequence = 0;
public constructor(options: BrowserToolsBrokerOptions) {
this.policy = options.policy;
this.defaultTimeoutMs = options.defaultTimeoutMs ?? DEFAULT_BROWSER_TOOLS_TIMEOUT_MS;
this.createRequestId = options.createRequestId ?? (() => `browser_${randomUUID()}`);
}
public registerClient(client: BrowserToolsDesktopClient): () => void {
this.clients.set(client.id, client);
return () => this.unregisterClient(client.id);
public registerClient(client: BrowserHostClient): () => void {
this.unregisterClient(client.id);
const registeredAt = ++this.registrationSequence;
this.clients.set(client.id, {
client,
registeredAt,
supportedCommands: new Set(client.supportedCommands),
});
return () => this.unregisterClient(client.id, registeredAt);
}
public unregisterClient(clientId: string): void {
const deleted = this.clients.delete(clientId);
if (!deleted) {
public unregisterClient(clientId: string, registeredAt?: number): void {
const current = this.clients.get(clientId);
if (!current || (registeredAt !== undefined && current.registeredAt !== registeredAt)) {
return;
}
this.clients.delete(clientId);
for (const [browserId, ownerClientId] of this.browserHostByBrowserId) {
if (ownerClientId !== clientId) {
continue;
}
this.browserHostByBrowserId.delete(browserId);
this.strandedBrowserHostByBrowserId.set(browserId, clientId);
}
for (const [requestId, pending] of this.pending) {
if (pending.clientId !== clientId) {
@@ -70,8 +94,8 @@ export class BrowserToolsBroker {
pending.resolve(
browserToolsFailure({
requestId,
code: "browser_no_desktop",
message: "The desktop browser automation client disconnected before responding.",
code: "browser_no_host",
message: "The browser automation host disconnected before responding.",
retryable: true,
}),
);
@@ -89,24 +113,6 @@ export class BrowserToolsBroker {
public async execute(input: BrowserToolsExecuteInput): Promise<BrowserToolsResponsePayload> {
const requestId = input.requestId ?? this.createRequestId();
if (!this.policy.isEnabled()) {
return browserToolsFailure({
requestId,
code: "browser_disabled",
message: "Browser tools are disabled. Enable daemon.browserTools.enabled to use them.",
});
}
const client = this.selectClient();
if (!client) {
return browserToolsFailure({
requestId,
code: "browser_no_desktop",
message: "No desktop browser automation client is connected.",
retryable: true,
});
}
const request = BrowserAutomationExecuteRequestSchema.safeParse({
type: "browser.automation.execute.request",
requestId,
@@ -124,8 +130,29 @@ export class BrowserToolsBroker {
});
}
if (request.data.command.command === "list_tabs") {
return this.executeListTabs({
request: request.data,
timeoutMs: input.timeoutMs ?? this.defaultTimeoutMs,
});
}
const host = this.selectHostForCommand(request.data.command, requestId);
if (!host.ok) {
return host.payload;
}
const unsupported = this.unsupportedCommandFailure({
host: host.value,
commandName: request.data.command.command,
requestId,
});
if (unsupported) {
return unsupported;
}
return this.sendRequest({
client,
host: host.value,
request: request.data,
timeoutMs: input.timeoutMs ?? this.defaultTimeoutMs,
});
@@ -163,23 +190,225 @@ export class BrowserToolsBroker {
this.pending.delete(parsed.data.payload.requestId);
clearTimeout(pending.timeout);
if (pending.rememberAffinity) {
this.rememberBrowserHostForPayload(pending.clientId, parsed.data.payload);
}
pending.resolve(parsed.data.payload);
return true;
}
private selectClient(): BrowserToolsDesktopClient | null {
for (const client of this.clients.values()) {
return client;
}
return null;
}
private sendRequest(params: {
client: BrowserToolsDesktopClient;
private async executeListTabs(params: {
request: BrowserAutomationExecuteRequest;
timeoutMs: number;
}): Promise<BrowserToolsResponsePayload> {
const { client, request, timeoutMs } = params;
const hosts = Array.from(this.clients.values());
if (hosts.length === 0) {
return this.noBrowserHostFailure(params.request.requestId);
}
for (const host of hosts) {
const unsupported = this.unsupportedCommandFailure({
host,
commandName: "list_tabs",
requestId: params.request.requestId,
});
if (unsupported) {
return unsupported;
}
}
if (hosts.length === 1) {
return this.sendRequest({
host: hosts[0],
request: params.request,
timeoutMs: params.timeoutMs,
});
}
const hostResponses = await Promise.all(
hosts.map(async (host) => ({
host,
payload: await this.sendRequest({
host,
request: {
...params.request,
requestId: `${params.request.requestId}:${host.client.id}`,
},
rememberAffinity: false,
timeoutMs: params.timeoutMs,
}),
})),
);
const failed = hostResponses.find(({ payload }) => !payload.ok);
if (failed) {
return withBrowserToolsRequestId(failed.payload, params.request.requestId);
}
for (const { host, payload } of hostResponses) {
this.rememberBrowserHostForPayload(host.client.id, payload);
}
return {
requestId: params.request.requestId,
ok: true,
result: {
command: "list_tabs",
tabs: hostResponses.flatMap(({ payload }) =>
payload.ok && payload.result.command === "list_tabs" ? payload.result.tabs : [],
),
},
};
}
private selectHostForCommand(
command: BrowserAutomationCommand,
requestId: string,
):
| { ok: true; value: RegisteredBrowserHost }
| { ok: false; payload: BrowserToolsResponsePayload } {
if (command.command === "new_tab") {
const host = this.selectMostRecentlyRegisteredHost();
return host
? { ok: true, value: host }
: { ok: false, payload: this.noBrowserHostFailure(requestId) };
}
const browserId = getBrowserIdForCommand(command);
if (!browserId) {
const host = this.selectMostRecentlyRegisteredHost();
return host
? { ok: true, value: host }
: { ok: false, payload: this.noBrowserHostFailure(requestId) };
}
const ownerClientId = this.browserHostByBrowserId.get(browserId);
if (ownerClientId) {
const host = this.clients.get(ownerClientId);
if (host) {
return { ok: true, value: host };
}
return {
ok: false,
payload: this.strandedBrowserTabFailure({ requestId, browserId }),
};
}
const strandedOwnerClientId = this.strandedBrowserHostByBrowserId.get(browserId);
if (strandedOwnerClientId) {
const reconnectedHost = this.clients.get(strandedOwnerClientId);
if (reconnectedHost) {
this.strandedBrowserHostByBrowserId.delete(browserId);
this.browserHostByBrowserId.set(browserId, strandedOwnerClientId);
return { ok: true, value: reconnectedHost };
}
return {
ok: false,
payload: this.strandedBrowserTabFailure({ requestId, browserId }),
};
}
if (this.clients.size === 1) {
const host = this.selectMostRecentlyRegisteredHost();
if (host) {
return { ok: true, value: host };
}
}
if (this.clients.size === 0) {
return { ok: false, payload: this.noBrowserHostFailure(requestId) };
}
return {
ok: false,
payload: browserToolsFailure({
requestId,
code: "browser_tab_not_found",
message: `Browser tab ${browserId} is not associated with a connected browser automation host. Call browser_list_tabs and use one of the returned browserId values.`,
}),
};
}
private selectMostRecentlyRegisteredHost(): RegisteredBrowserHost | null {
let selected: RegisteredBrowserHost | null = null;
for (const host of this.clients.values()) {
selected = host;
}
return selected;
}
private unsupportedCommandFailure(params: {
host: RegisteredBrowserHost;
commandName: BrowserAutomationCommandName;
requestId: string;
}): BrowserToolsResponsePayload | null {
if (params.host.supportedCommands.has(params.commandName)) {
return null;
}
return browserToolsFailure({
requestId: params.requestId,
code: "browser_unsupported",
message: `Browser automation command "${params.commandName}" is not supported by the ${describeBrowserHost(params.host)}.`,
});
}
private noBrowserHostFailure(requestId: string): BrowserToolsResponsePayload {
return browserToolsFailure({
requestId,
code: "browser_no_host",
message: "No browser automation host is connected.",
retryable: true,
});
}
private strandedBrowserTabFailure(params: {
requestId: string;
browserId: string;
}): BrowserToolsResponsePayload {
return browserToolsFailure({
requestId: params.requestId,
code: "browser_no_host",
message: `The app hosting browser tab ${params.browserId} disconnected.`,
retryable: true,
});
}
private rememberBrowserHostForPayload(
clientId: string,
payload: BrowserToolsResponsePayload,
): void {
if (!payload.ok) {
return;
}
if (payload.result.command === "list_tabs") {
for (const tab of payload.result.tabs) {
this.browserHostByBrowserId.set(tab.browserId, clientId);
this.strandedBrowserHostByBrowserId.delete(tab.browserId);
}
return;
}
if (payload.result.command === "close_tab") {
this.browserHostByBrowserId.delete(payload.result.browserId);
this.strandedBrowserHostByBrowserId.delete(payload.result.browserId);
return;
}
if ("browserId" in payload.result) {
this.browserHostByBrowserId.set(payload.result.browserId, clientId);
this.strandedBrowserHostByBrowserId.delete(payload.result.browserId);
}
}
private sendRequest(params: {
host: RegisteredBrowserHost;
request: BrowserAutomationExecuteRequest;
rememberAffinity?: boolean;
timeoutMs: number;
}): Promise<BrowserToolsResponsePayload> {
const { host, request, timeoutMs } = params;
const client = host.client;
return new Promise<BrowserToolsResponsePayload>((resolve) => {
const timeout = setTimeout(() => {
@@ -198,6 +427,7 @@ export class BrowserToolsBroker {
this.pending.set(request.requestId, {
clientId: client.id,
rememberAffinity: params.rememberAffinity ?? true,
timeout,
resolve,
});
@@ -225,6 +455,25 @@ export class BrowserToolsBroker {
}
}
function getBrowserIdForCommand(command: BrowserAutomationCommand): string | null {
if (command.command === "list_tabs" || command.command === "new_tab") {
return null;
}
return command.args.browserId;
}
function describeBrowserHost(host: RegisteredBrowserHost): string {
const hostKind = host.client.hostKind.trim();
return hostKind || "browser host";
}
function withBrowserToolsRequestId(
payload: BrowserToolsResponsePayload,
requestId: string,
): BrowserToolsResponsePayload {
return { ...payload, requestId } as BrowserToolsResponsePayload;
}
function resolveSendFailure(params: {
requestId: string;
pending: Map<string, PendingBrowserToolsRequest>;
@@ -4,14 +4,6 @@ export interface BrowserToolsPolicy {
isEnabled(): boolean;
}
export class StaticBrowserToolsPolicy implements BrowserToolsPolicy {
public constructor(private readonly enabled: boolean) {}
public isEnabled(): boolean {
return this.enabled;
}
}
export class DaemonConfigBrowserToolsPolicy implements BrowserToolsPolicy {
public constructor(private readonly configStore: Pick<DaemonConfigStore, "get">) {}

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