mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-14 08:03:58 +00:00
Compare commits
4 Commits
exp/click_
...
fix/gate-u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72a2cc1d8a | ||
|
|
fdee7f91f2 | ||
|
|
90691c03b9 | ||
|
|
fa07dc2a22 |
157
.github/workflows/build-agent.yml
vendored
157
.github/workflows/build-agent.yml
vendored
@@ -1,157 +0,0 @@
|
||||
name: build-agent
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
agent:
|
||||
description: "Agent name from bundle.json"
|
||||
required: true
|
||||
type: string
|
||||
default: openclaw
|
||||
publish:
|
||||
description: "Upload to R2 and merge manifest slice"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
pull_request:
|
||||
paths:
|
||||
- "packages/browseros-agent/packages/build-tools/**"
|
||||
- ".github/workflows/build-agent.yml"
|
||||
|
||||
env:
|
||||
BUN_VERSION: "1.3.6"
|
||||
PKG_DIR: packages/browseros-agent/packages/build-tools
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
- working-directory: packages/browseros-agent
|
||||
run: bun install --frozen-lockfile
|
||||
- working-directory: packages/browseros-agent
|
||||
run: bun run --filter @browseros/build-tools typecheck
|
||||
- working-directory: packages/browseros-agent
|
||||
run: bun run --filter @browseros/build-tools test
|
||||
|
||||
build:
|
||||
needs: check
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
- name: Install podman
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y podman
|
||||
- working-directory: packages/browseros-agent
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Build tarball
|
||||
working-directory: ${{ env.PKG_DIR }}
|
||||
env:
|
||||
AGENT: ${{ inputs.agent || 'openclaw' }}
|
||||
OUT: ${{ github.workspace }}/dist/images
|
||||
run: bun run build:tarball -- --agent "$AGENT" --arch "${{ matrix.arch }}" --output-dir "$OUT"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: tarball-${{ inputs.agent || 'openclaw' }}-${{ matrix.arch }}
|
||||
path: dist/images/
|
||||
retention-days: 7
|
||||
|
||||
smoke:
|
||||
needs: build
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: tarball-${{ inputs.agent || 'openclaw' }}-arm64
|
||||
path: dist/images
|
||||
- name: Install podman
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y podman
|
||||
- working-directory: packages/browseros-agent
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Smoke test tarball
|
||||
working-directory: ${{ env.PKG_DIR }}
|
||||
env:
|
||||
AGENT: ${{ inputs.agent || 'openclaw' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tarball="$(find "$GITHUB_WORKSPACE/dist/images" -name "${AGENT}-*-arm64.tar.gz" -print -quit)"
|
||||
if [ -z "$tarball" ]; then
|
||||
echo "missing arm64 tarball artifact for ${AGENT}" >&2
|
||||
exit 1
|
||||
fi
|
||||
bun run smoke:tarball -- --agent "$AGENT" --arch arm64 --tarball "$tarball"
|
||||
|
||||
publish:
|
||||
needs: [build, smoke]
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish == true }}
|
||||
runs-on: ubuntu-24.04
|
||||
environment: release
|
||||
concurrency:
|
||||
group: r2-manifest-publish
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: tarball-*
|
||||
path: dist/images
|
||||
merge-multiple: true
|
||||
- working-directory: packages/browseros-agent
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Upload tarballs to R2
|
||||
working-directory: ${{ env.PKG_DIR }}
|
||||
env:
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for file in "$GITHUB_WORKSPACE"/dist/images/*.tar.gz; do
|
||||
base="$(basename "$file")"
|
||||
bun run upload -- --file "$file" --key "vm/images/$base" --content-type "application/gzip" --sidecar-sha
|
||||
done
|
||||
- name: Merge agent slice into manifest
|
||||
working-directory: ${{ env.PKG_DIR }}
|
||||
env:
|
||||
AGENT: ${{ inputs.agent || 'openclaw' }}
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p dist/images
|
||||
cp -R "$GITHUB_WORKSPACE"/dist/images/* dist/images/
|
||||
bun run download -- --key vm/manifest.json --out dist/baseline-manifest.json
|
||||
bun run emit-manifest -- \
|
||||
--slice "agents:${AGENT}" \
|
||||
--dist-dir dist \
|
||||
--merge-from dist/baseline-manifest.json \
|
||||
--out dist/manifest.json
|
||||
bun run upload -- --file dist/manifest.json --key vm/manifest.json --content-type "application/json"
|
||||
201
.github/workflows/test.yml
vendored
201
.github/workflows/test.yml
vendored
@@ -30,54 +30,15 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- suite: server-agent
|
||||
command: (cd apps/server && bun run test:agent)
|
||||
junit_path: test-results/server-agent.xml
|
||||
needs_browser: false
|
||||
- suite: server-api
|
||||
command: (cd apps/server && bun run test:api)
|
||||
junit_path: test-results/server-api.xml
|
||||
needs_browser: false
|
||||
- suite: server-skills
|
||||
command: (cd apps/server && bun run test:skills)
|
||||
junit_path: test-results/server-skills.xml
|
||||
needs_browser: false
|
||||
- suite: server-tools
|
||||
command: (cd apps/server && bun run test:tools)
|
||||
junit_path: test-results/server-tools.xml
|
||||
needs_browser: true
|
||||
- suite: server-browser
|
||||
command: (cd apps/server && bun run test:browser)
|
||||
junit_path: test-results/server-browser.xml
|
||||
needs_browser: false
|
||||
- suite: server-integration
|
||||
command: (cd apps/server && bun run test:integration)
|
||||
junit_path: test-results/server-integration.xml
|
||||
needs_browser: true
|
||||
- suite: server-sdk
|
||||
command: (cd apps/server && bun run test:sdk)
|
||||
junit_path: test-results/server-sdk.xml
|
||||
needs_browser: true
|
||||
- suite: server-root
|
||||
command: (cd apps/server && bun run test:root)
|
||||
junit_path: test-results/server-root.xml
|
||||
needs_browser: false
|
||||
- suite: agent
|
||||
command: bun run test:agent
|
||||
junit_path: test-results/agent.xml
|
||||
needs_browser: false
|
||||
- suite: eval
|
||||
command: bun run test:eval
|
||||
junit_path: test-results/eval.xml
|
||||
needs_browser: false
|
||||
- suite: agent-sdk
|
||||
command: bun run test:agent-sdk
|
||||
junit_path: test-results/agent-sdk.xml
|
||||
needs_browser: false
|
||||
- suite: build
|
||||
command: bun run test:build
|
||||
junit_path: test-results/build.xml
|
||||
needs_browser: false
|
||||
- suite: tools
|
||||
test_path: tests/tools
|
||||
junit_path: test-results/tools.xml
|
||||
- suite: integration
|
||||
test_path: tests/server.integration.test.ts
|
||||
junit_path: test-results/integration.xml
|
||||
- suite: sdk
|
||||
test_path: tests/sdk
|
||||
junit_path: test-results/sdk.xml
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -90,7 +51,6 @@ jobs:
|
||||
run: bun ci
|
||||
|
||||
- name: Resolve BrowserOS cache key
|
||||
if: matrix.needs_browser == true
|
||||
id: browseros-cache-key
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -105,7 +65,6 @@ jobs:
|
||||
echo "key=browseros-appimage-${{ runner.os }}-$cache_key" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore BrowserOS cache
|
||||
if: matrix.needs_browser == true
|
||||
id: browseros-cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -113,14 +72,13 @@ jobs:
|
||||
key: ${{ steps.browseros-cache-key.outputs.key }}
|
||||
|
||||
- name: Download BrowserOS
|
||||
if: matrix.needs_browser == true && steps.browseros-cache.outputs.cache-hit != 'true'
|
||||
if: steps.browseros-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
mkdir -p .ci/bin
|
||||
curl -fsSL "$BROWSEROS_APPIMAGE_URL" -o .ci/bin/BrowserOS.AppImage
|
||||
chmod +x .ci/bin/BrowserOS.AppImage
|
||||
|
||||
- name: Prepare BrowserOS wrapper
|
||||
if: matrix.needs_browser == true
|
||||
run: |
|
||||
mkdir -p .ci/bin
|
||||
cat > .ci/bin/browseros <<'EOF'
|
||||
@@ -141,23 +99,16 @@ jobs:
|
||||
BROWSEROS_BINARY: ${{ github.workspace }}/packages/browseros-agent/.ci/bin/browseros
|
||||
BROWSEROS_TEST_HEADLESS: "true"
|
||||
BROWSEROS_TEST_EXTRA_ARGS: --no-sandbox --disable-dev-shm-usage
|
||||
BROWSEROS_JUNIT_PATH: ${{ github.workspace }}/packages/browseros-agent/${{ matrix.junit_path }}
|
||||
run: |
|
||||
set +e
|
||||
mkdir -p test-results
|
||||
${{ matrix.command }}
|
||||
cd apps/server
|
||||
bun run test:cleanup
|
||||
bun --env-file=.env.development test "${{ matrix.test_path }}" --reporter=junit --reporter-outfile="../../${{ matrix.junit_path }}"
|
||||
exit_code=$?
|
||||
cd ../..
|
||||
if [ ! -f "${{ matrix.junit_path }}" ]; then
|
||||
if [ "$exit_code" = "0" ]; then
|
||||
cat > "${{ matrix.junit_path }}" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<testsuites tests="0" failures="0">
|
||||
<testsuite name="${{ matrix.suite }}" tests="0" failures="0">
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
EOF
|
||||
else
|
||||
cat > "${{ matrix.junit_path }}" <<EOF
|
||||
cat > "${{ matrix.junit_path }}" <<EOF
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<testsuites tests="1" failures="1">
|
||||
<testsuite name="${{ matrix.suite }}" tests="1" failures="1">
|
||||
@@ -167,7 +118,6 @@ jobs:
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
EOF
|
||||
fi
|
||||
fi
|
||||
echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -189,124 +139,3 @@ jobs:
|
||||
echo "See the uploaded \`junit-${{ matrix.suite }}\` artifact for details." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
comment:
|
||||
name: PR test summary
|
||||
needs: test
|
||||
if: >-
|
||||
always()
|
||||
&& github.event_name == 'pull_request'
|
||||
&& github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
actions: read
|
||||
steps:
|
||||
- name: Download JUnit artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: junit
|
||||
pattern: junit-*
|
||||
|
||||
- name: Build comment body
|
||||
run: |
|
||||
python3 <<'PY'
|
||||
import glob, os, xml.etree.ElementTree as ET
|
||||
|
||||
run_url = f"{os.environ['GITHUB_SERVER_URL']}/{os.environ['GITHUB_REPOSITORY']}/actions/runs/{os.environ['GITHUB_RUN_ID']}"
|
||||
marker = "<!-- browseros-agent-tests-summary -->"
|
||||
|
||||
suites = []
|
||||
failed_cases = []
|
||||
total_tests = total_failed = total_skipped = 0
|
||||
|
||||
for xml_path in sorted(glob.glob("junit/junit-*/*.xml")):
|
||||
suite_name = os.path.basename(os.path.dirname(xml_path)).removeprefix("junit-")
|
||||
try:
|
||||
root = ET.parse(xml_path).getroot()
|
||||
except ET.ParseError:
|
||||
suites.append({"name": suite_name, "passed": 0, "failed": 1, "skipped": 0, "total": 1})
|
||||
total_tests += 1
|
||||
total_failed += 1
|
||||
failed_cases.append((suite_name, "(could not parse junit XML)"))
|
||||
continue
|
||||
|
||||
testsuites = root.findall("testsuite") if root.tag == "testsuites" else [root]
|
||||
s_tests = s_fail = s_err = s_skip = 0
|
||||
for ts in testsuites:
|
||||
s_tests += int(ts.get("tests") or 0)
|
||||
s_fail += int(ts.get("failures") or 0)
|
||||
s_err += int(ts.get("errors") or 0)
|
||||
s_skip += int(ts.get("skipped") or 0)
|
||||
for tc in ts.iter("testcase"):
|
||||
if tc.find("failure") is not None or tc.find("error") is not None:
|
||||
cls = tc.get("classname") or ""
|
||||
name = tc.get("name") or "(unnamed)"
|
||||
label = f"{cls} > {name}" if cls else name
|
||||
failed_cases.append((suite_name, label))
|
||||
|
||||
s_failed = s_fail + s_err
|
||||
s_passed = max(s_tests - s_failed - s_skip, 0)
|
||||
suites.append({"name": suite_name, "passed": s_passed, "failed": s_failed, "skipped": s_skip, "total": s_tests})
|
||||
total_tests += s_tests
|
||||
total_failed += s_failed
|
||||
total_skipped += s_skip
|
||||
|
||||
total_passed = max(total_tests - total_failed - total_skipped, 0)
|
||||
|
||||
if total_tests == 0:
|
||||
header = "## :warning: No test results were produced"
|
||||
elif total_failed == 0:
|
||||
header = f"## :white_check_mark: Tests passed — {total_passed}/{total_tests}"
|
||||
else:
|
||||
header = f"## :x: Tests failed — {total_failed}/{total_tests} failed"
|
||||
|
||||
lines = [marker, header, ""]
|
||||
if suites:
|
||||
lines.append("| Suite | Passed | Failed | Skipped |")
|
||||
lines.append("|-------|--------|--------|---------|")
|
||||
for s in suites:
|
||||
icon = ":white_check_mark:" if s["failed"] == 0 and s["total"] > 0 else ":warning:" if s["total"] == 0 else ":x:"
|
||||
lines.append(f"| {icon} `{s['name']}` | {s['passed']}/{s['total']} | {s['failed']} | {s['skipped']} |")
|
||||
|
||||
if failed_cases:
|
||||
lines += ["", "<details open>", "<summary><b>Failed tests</b></summary>", ""]
|
||||
for suite_name, label in failed_cases[:50]:
|
||||
lines.append(f"- **{suite_name}** — `{label}`")
|
||||
if len(failed_cases) > 50:
|
||||
lines.append(f"- …and {len(failed_cases) - 50} more")
|
||||
lines += ["", "</details>"]
|
||||
|
||||
lines += ["", f"[View workflow run]({run_url})"]
|
||||
|
||||
with open("comment.md", "w") as f:
|
||||
f.write("\n".join(lines) + "\n")
|
||||
PY
|
||||
|
||||
- name: Upsert sticky PR comment
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const body = fs.readFileSync('comment.md', 'utf8');
|
||||
const marker = '<!-- browseros-agent-tests-summary -->';
|
||||
const { owner, repo } = context.repo;
|
||||
const issue_number = context.payload.pull_request.number;
|
||||
|
||||
const triggerSha = context.payload.pull_request.head.sha;
|
||||
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: issue_number });
|
||||
if (pr.head.sha !== triggerSha) {
|
||||
core.info(`PR head has moved (${pr.head.sha} vs ${triggerSha}) — skipping stale comment.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner, repo, issue_number, per_page: 100,
|
||||
});
|
||||
const existing = comments.find(c => c.body && c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
|
||||
} else {
|
||||
await github.rest.issues.createComment({ owner, repo, issue_number, body });
|
||||
}
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,6 +1,4 @@
|
||||
**/.DS_Store
|
||||
**.auctor/**
|
||||
.auctor.json
|
||||
.gcs_entries
|
||||
**/dmg
|
||||
**/env
|
||||
@@ -33,4 +31,3 @@ packages/browseros/build/tools/
|
||||
# AI SDK DevTools traces
|
||||
.devtools/
|
||||
.omc/
|
||||
packages/browseros-agent/tools/dogfood/browseros-dogfood
|
||||
|
||||
1
packages/browseros-agent/.gitignore
vendored
1
packages/browseros-agent/.gitignore
vendored
@@ -14,7 +14,6 @@ lerna-debug.log*
|
||||
# Ignore all .env files except .env.example
|
||||
**/.env.*
|
||||
!**/.env.example
|
||||
!**/.env.sample
|
||||
!**/.env.production.example
|
||||
|
||||
|
||||
|
||||
@@ -218,9 +218,3 @@ This uses the same element resolution as the server's MCP tools — no coordinat
|
||||
The `<target>` argument can be:
|
||||
- An **index** from the `targets` output (e.g., `3`)
|
||||
- A **URL substring** (e.g., `sidepanel`, `newtab`, `chrome-extension://`)
|
||||
|
||||
## Release gating — bundled-VM runtime migration (2026-Q2)
|
||||
|
||||
Between the Lima server-prod-resources cutover (WS3) and the ContainerRuntime migration (WS6) landing, `resources/bin/third_party/` ships `limactl` instead of `podman`. The current OpenClaw runtime (`apps/server/src/api/services/openclaw/podman-runtime.ts`, `container-runtime.ts`) still invokes `podman`; it will fail to find the binary on builds cut from `dev`.
|
||||
|
||||
Do **not** cut a release branch off `dev` during this window. Track WS6 progress before any release cut. See `specs/bundled-vm-runtime-spec.md` + `specs/workstreams.md` for context.
|
||||
|
||||
@@ -75,20 +75,26 @@ packages/
|
||||
|
||||
### Setup
|
||||
|
||||
Requires [process-compose](https://github.com/F1bonacc1/process-compose):
|
||||
|
||||
```bash
|
||||
brew install process-compose
|
||||
```
|
||||
|
||||
```bash
|
||||
# Copy environment files for each package
|
||||
cp apps/server/.env.example apps/server/.env.development
|
||||
cp apps/agent/.env.example apps/agent/.env.development
|
||||
cp apps/server/.env.production.example apps/server/.env.production
|
||||
|
||||
# Install deps, generate agent code, and sync the VM cache
|
||||
bun run dev:setup
|
||||
|
||||
# Start the full dev environment
|
||||
bun run dev:watch
|
||||
process-compose up
|
||||
```
|
||||
|
||||
`dev:watch` exits when the VM cache manifest is missing, but setup stays in `dev:setup`.
|
||||
The `process-compose up` command runs the following in order:
|
||||
1. `bun install` — installs dependencies
|
||||
2. `bun --cwd apps/agent codegen` — generates agent code
|
||||
3. `bun --cwd apps/server start` and `bun --cwd apps/agent dev` — starts server and agent in parallel
|
||||
|
||||
### Environment Variables
|
||||
|
||||
|
||||
@@ -74,18 +74,6 @@ const primaryNavItems: NavItem[] = [
|
||||
{ name: 'Settings', to: '/settings/ai', icon: Settings },
|
||||
]
|
||||
|
||||
function isNavItemActive(item: NavItem, pathname: string): boolean {
|
||||
if (item.to === '/settings/ai') {
|
||||
return pathname.startsWith('/settings')
|
||||
}
|
||||
|
||||
if (item.to === '/agents') {
|
||||
return pathname === '/agents' || pathname.startsWith('/agents/')
|
||||
}
|
||||
|
||||
return pathname === item.to
|
||||
}
|
||||
|
||||
export const SidebarNavigation: FC<SidebarNavigationProps> = ({
|
||||
expanded = true,
|
||||
}) => {
|
||||
@@ -102,7 +90,10 @@ export const SidebarNavigation: FC<SidebarNavigationProps> = ({
|
||||
<nav className="space-y-1">
|
||||
{filteredItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
const isActive = isNavItemActive(item, location.pathname)
|
||||
const isActive =
|
||||
item.to === '/settings/ai'
|
||||
? location.pathname.startsWith('/settings')
|
||||
: location.pathname === item.to
|
||||
|
||||
const navItem = (
|
||||
<NavLink
|
||||
|
||||
@@ -113,22 +113,7 @@ export const App: FC = () => {
|
||||
<Route path="connect-apps" element={<ConnectMCP />} />
|
||||
<Route path="scheduled" element={<ScheduledTasksPage />} />
|
||||
{alphaEnabled ? (
|
||||
<>
|
||||
<Route path="agents" element={<AgentsPage />} />
|
||||
<Route element={<AgentCommandLayout />}>
|
||||
<Route
|
||||
path="agents/:agentId"
|
||||
element={
|
||||
<AgentCommandConversation
|
||||
variant="page"
|
||||
backPath="/agents"
|
||||
agentPathPrefix="/agents"
|
||||
createAgentPath="/agents"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</>
|
||||
<Route path="agents" element={<AgentsPage />} />
|
||||
) : null}
|
||||
{alphaEnabled ? (
|
||||
<Route path="admin" element={<AdminDashboardPage />} />
|
||||
|
||||
@@ -1,318 +1,189 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { ArrowLeft, Bot, Home } from 'lucide-react'
|
||||
import { type FC, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Bot, Home, RotateCcw } from 'lucide-react'
|
||||
import { type FC, useEffect, useRef } from 'react'
|
||||
import { Navigate, useNavigate, useParams, useSearchParams } from 'react-router'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
type AgentEntry,
|
||||
getModelDisplayName,
|
||||
} from '@/entrypoints/app/agents/useOpenClaw'
|
||||
import type { AgentEntry } from '@/entrypoints/app/agents/useOpenClaw'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useAgentCommandData } from './agent-command-layout'
|
||||
import { ClawChat } from './ClawChat'
|
||||
import { ConversationInput } from './ConversationInput'
|
||||
import {
|
||||
buildChatHistoryFromClawMessages,
|
||||
flattenHistoryPages,
|
||||
} from './claw-chat-types'
|
||||
import { ConversationMessage } from './ConversationMessage'
|
||||
import { useAgentConversation } from './useAgentConversation'
|
||||
import {
|
||||
CLAW_CHAT_QUERY_KEYS,
|
||||
useClawAgentSession,
|
||||
useClawChatHistory,
|
||||
} from './useClawChatHistory'
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
return (
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-card px-3 py-1 text-[11px] text-muted-foreground uppercase tracking-[0.18em]">
|
||||
<span
|
||||
className={cn(
|
||||
'size-1.5 rounded-full',
|
||||
status === 'Working on your request'
|
||||
? 'bg-amber-500'
|
||||
: status === 'Ready'
|
||||
? 'bg-emerald-500'
|
||||
: status === 'Offline'
|
||||
? 'bg-muted-foreground/50'
|
||||
: 'bg-[var(--accent-orange)]',
|
||||
)}
|
||||
/>
|
||||
<span>{status}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentIdentity({
|
||||
name,
|
||||
meta,
|
||||
className,
|
||||
}: {
|
||||
name: string
|
||||
meta: string
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('min-w-0', className)}>
|
||||
<div className="truncate font-semibold text-[15px] leading-5">{name}</div>
|
||||
<div className="truncate text-muted-foreground text-xs leading-5">
|
||||
{meta}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConversationHeader({
|
||||
agentName,
|
||||
agentMeta,
|
||||
status,
|
||||
backLabel,
|
||||
backTarget,
|
||||
onGoHome,
|
||||
onReset,
|
||||
}: {
|
||||
agentName: string
|
||||
agentMeta: string
|
||||
status: string
|
||||
backLabel: string
|
||||
backTarget: 'home' | 'page'
|
||||
onGoHome: () => void
|
||||
onReset: () => void
|
||||
}) {
|
||||
const BackIcon = backTarget === 'home' ? Home : ArrowLeft
|
||||
|
||||
return (
|
||||
<div className="flex h-14 items-center justify-between gap-4 border-border/50 border-b px-5">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="overflow-hidden rounded-[1.5rem] border border-border/60 bg-card/95 shadow-sm backdrop-blur">
|
||||
<div className="flex items-center justify-between gap-3 px-5 py-4">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onGoHome}
|
||||
className="rounded-xl"
|
||||
title="Back to home"
|
||||
>
|
||||
<Home className="size-4" />
|
||||
</Button>
|
||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
|
||||
<Bot className="size-5" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-semibold text-sm">{agentName}</div>
|
||||
<div className="truncate text-muted-foreground text-sm">
|
||||
{status}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onGoHome}
|
||||
className="size-8 rounded-xl lg:hidden"
|
||||
title={backLabel}
|
||||
size="sm"
|
||||
onClick={onReset}
|
||||
className="rounded-xl text-muted-foreground"
|
||||
>
|
||||
<BackIcon className="size-4" />
|
||||
<RotateCcw className="mr-2 size-4" />
|
||||
New conversation
|
||||
</Button>
|
||||
<div className="flex size-8 shrink-0 items-center justify-center rounded-xl bg-muted text-muted-foreground">
|
||||
<Bot className="size-4" />
|
||||
</div>
|
||||
<AgentIdentity name={agentName} meta={agentMeta} />
|
||||
</div>
|
||||
|
||||
<StatusBadge status={status} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentRailHeader({ onGoHome }: { onGoHome: () => void }) {
|
||||
return (
|
||||
<div className="hidden h-14 items-center border-border/50 border-r border-b bg-background/70 px-4 lg:flex">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onGoHome}
|
||||
className="size-8 rounded-xl"
|
||||
title="Back to home"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<div className="truncate font-semibold text-[15px] leading-5">
|
||||
Agents
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentRailList({
|
||||
activeAgentId,
|
||||
agents,
|
||||
onSelectAgent,
|
||||
}: {
|
||||
activeAgentId: string
|
||||
agents: AgentEntry[]
|
||||
onSelectAgent: (entry: AgentEntry) => void
|
||||
}) {
|
||||
function EmptyConversationState({ agentName }: { agentName: string }) {
|
||||
return (
|
||||
<aside className="hidden min-h-0 flex-col border-border/50 border-r bg-background/70 lg:flex">
|
||||
<div className="styled-scrollbar min-h-0 flex-1 space-y-2 overflow-y-auto px-3 py-3">
|
||||
{agents.map((entry) => {
|
||||
const active = entry.agentId === activeAgentId
|
||||
const modelName = getModelDisplayName(entry.model) ?? 'OpenClaw agent'
|
||||
|
||||
return (
|
||||
<button
|
||||
key={entry.agentId}
|
||||
type="button"
|
||||
onClick={() => onSelectAgent(entry)}
|
||||
className={cn(
|
||||
'w-full rounded-2xl border px-3 py-3 text-left transition-all',
|
||||
active
|
||||
? 'border-[var(--accent-orange)]/30 bg-[var(--accent-orange)]/8 shadow-sm'
|
||||
: 'border-transparent bg-transparent hover:border-border/60 hover:bg-card',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-9 items-center justify-center rounded-xl',
|
||||
active
|
||||
? 'bg-[var(--accent-orange)]/12 text-[var(--accent-orange)]'
|
||||
: 'bg-muted text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<Bot className="size-4" />
|
||||
</div>
|
||||
<AgentIdentity name={entry.name} meta={modelName} />
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<div className="flex min-h-full items-center justify-center py-10">
|
||||
<div className="max-w-md rounded-[1.5rem] border border-border/60 bg-card/90 px-8 py-10 text-center shadow-sm backdrop-blur">
|
||||
<div className="mx-auto flex size-14 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
|
||||
<Bot className="size-6" />
|
||||
</div>
|
||||
<h2 className="mt-4 font-semibold text-lg">{agentName}</h2>
|
||||
<p className="mt-2 text-muted-foreground text-sm">
|
||||
Send a message to start a focused conversation with this agent.
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getConversationStatusCopy(status: string | undefined): string {
|
||||
if (status === 'running') return 'Ready'
|
||||
if (status === 'starting') return 'Connecting'
|
||||
if (status === 'error') return 'Attention'
|
||||
if (status === 'stopped') return 'Offline'
|
||||
return 'Setup'
|
||||
function getConversationStatusCopy(
|
||||
status: string | undefined,
|
||||
streaming: boolean,
|
||||
): string {
|
||||
if (streaming) return 'Working on your request'
|
||||
if (status === 'running') return 'Ready for the next task'
|
||||
if (status === 'starting') return 'Connecting to OpenClaw'
|
||||
if (status === 'error') return 'OpenClaw needs attention'
|
||||
if (status === 'stopped') return 'OpenClaw is offline'
|
||||
return 'Open agent setup to continue'
|
||||
}
|
||||
|
||||
function AgentConversationController({
|
||||
agentId,
|
||||
initialMessage,
|
||||
onInitialMessageConsumed,
|
||||
status,
|
||||
agents,
|
||||
agentPathPrefix,
|
||||
createAgentPath,
|
||||
}: {
|
||||
agentId: string
|
||||
initialMessage: string | null
|
||||
onInitialMessageConsumed: () => void
|
||||
status: ReturnType<typeof useAgentCommandData>['status']
|
||||
agents: AgentEntry[]
|
||||
agentPathPrefix: string
|
||||
createAgentPath: string
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
export const AgentCommandConversation: FC = () => {
|
||||
const { agentId } = useParams<{ agentId: string }>()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const initialMessageSentRef = useRef<string | null>(null)
|
||||
const onInitialMessageConsumedRef = useRef(onInitialMessageConsumed)
|
||||
const [streamSessionKey, setStreamSessionKey] = useState<string | null>(null)
|
||||
const agent = agents.find((entry) => entry.agentId === agentId)
|
||||
const agentName = agent?.name || agentId || 'Agent'
|
||||
const sessionQuery = useClawAgentSession(agentId)
|
||||
const resolvedSessionKey =
|
||||
streamSessionKey ?? sessionQuery.data?.sessionKey ?? null
|
||||
const historyQuery = useClawChatHistory({
|
||||
agentId,
|
||||
sessionKey: resolvedSessionKey,
|
||||
enabled: Boolean(resolvedSessionKey),
|
||||
})
|
||||
|
||||
const historyMessages = useMemo(
|
||||
() => flattenHistoryPages(historyQuery.data?.pages ?? []),
|
||||
[historyQuery.data?.pages],
|
||||
)
|
||||
const chatHistory = useMemo(
|
||||
() => buildChatHistoryFromClawMessages(historyMessages),
|
||||
[historyMessages],
|
||||
)
|
||||
|
||||
const { turns, streaming, send } = useAgentConversation(agentId, {
|
||||
sessionKey: resolvedSessionKey,
|
||||
history: chatHistory,
|
||||
onSessionKeyChange: (sessionKey) => {
|
||||
setStreamSessionKey(sessionKey)
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: [CLAW_CHAT_QUERY_KEYS.session],
|
||||
})
|
||||
},
|
||||
})
|
||||
const sendRef = useRef(send)
|
||||
sendRef.current = send
|
||||
onInitialMessageConsumedRef.current = onInitialMessageConsumed
|
||||
|
||||
const disabled = status?.status !== 'running'
|
||||
const isInitialLoading =
|
||||
sessionQuery.isLoading ||
|
||||
(Boolean(resolvedSessionKey) && historyQuery.isLoading)
|
||||
const historyReady =
|
||||
!resolvedSessionKey || historyQuery.isFetched || historyQuery.isError
|
||||
const initialMessageKey = initialMessage
|
||||
? `${agentId}:${initialMessage}`
|
||||
: null
|
||||
const error = sessionQuery.error ?? historyQuery.error ?? null
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const initialQuerySent = useRef(false)
|
||||
const { status, agents } = useAgentCommandData()
|
||||
const shouldRedirectHome = !agentId
|
||||
const resolvedAgentId = agentId ?? ''
|
||||
const agent = agents.find((entry) => entry.agentId === resolvedAgentId)
|
||||
const agentName = agent?.name || resolvedAgentId || 'Agent'
|
||||
const { turns, streaming, loading, send, resetConversation } =
|
||||
useAgentConversation(resolvedAgentId, agentName)
|
||||
const lastTurn = turns[turns.length - 1]
|
||||
const lastTurnPartCount = lastTurn?.parts.length ?? 0
|
||||
|
||||
useEffect(() => {
|
||||
const query = initialMessage?.trim()
|
||||
if (!initialMessageKey) {
|
||||
initialMessageSentRef.current = null
|
||||
return
|
||||
}
|
||||
if (shouldRedirectHome) return
|
||||
|
||||
const query = searchParams.get('q')
|
||||
if (query && !initialQuerySent.current && !loading) {
|
||||
initialQuerySent.current = true
|
||||
setSearchParams({}, { replace: true })
|
||||
void send(query)
|
||||
}
|
||||
}, [loading, searchParams, send, setSearchParams, shouldRedirectHome])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!query ||
|
||||
initialMessageSentRef.current === initialMessageKey ||
|
||||
disabled ||
|
||||
sessionQuery.isLoading ||
|
||||
!historyReady ||
|
||||
streaming
|
||||
shouldRedirectHome ||
|
||||
(turns.length === 0 && lastTurnPartCount === 0 && !streaming)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
initialMessageSentRef.current = initialMessageKey
|
||||
onInitialMessageConsumedRef.current()
|
||||
void sendRef.current(query)
|
||||
}, [
|
||||
disabled,
|
||||
historyReady,
|
||||
initialMessage,
|
||||
initialMessageKey,
|
||||
sessionQuery.isLoading,
|
||||
streaming,
|
||||
])
|
||||
scrollRef.current?.scrollTo({
|
||||
top: scrollRef.current.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}, [lastTurnPartCount, shouldRedirectHome, streaming, turns.length])
|
||||
|
||||
const handleSelectAgent = (entry: AgentEntry) => {
|
||||
navigate(`${agentPathPrefix}/${entry.agentId}`)
|
||||
if (shouldRedirectHome) {
|
||||
return <Navigate to="/home" replace />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-col overflow-hidden">
|
||||
<ClawChat
|
||||
agentName={agentName}
|
||||
historyMessages={historyMessages}
|
||||
turns={turns}
|
||||
streaming={streaming}
|
||||
isInitialLoading={isInitialLoading}
|
||||
error={error}
|
||||
hasNextPage={Boolean(historyQuery.hasNextPage)}
|
||||
isFetchingNextPage={historyQuery.isFetchingNextPage}
|
||||
onFetchNextPage={() => {
|
||||
void historyQuery.fetchNextPage()
|
||||
}}
|
||||
onRetry={() => {
|
||||
void sessionQuery.refetch()
|
||||
void historyQuery.refetch()
|
||||
}}
|
||||
/>
|
||||
const handleSelectAgent = (entry: AgentEntry) => {
|
||||
navigate(`/home/agents/${entry.agentId}`)
|
||||
}
|
||||
|
||||
<div className="border-border/50 border-t bg-background/88 px-4 py-3 backdrop-blur-md">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
const statusCopy = getConversationStatusCopy(status?.status, streaming)
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="fade-in slide-in-from-bottom-5 mx-auto flex h-full w-full max-w-3xl animate-in flex-col gap-3 px-4 pt-4 pb-2 duration-300">
|
||||
<ConversationHeader
|
||||
agentName={agentName}
|
||||
status={statusCopy}
|
||||
onGoHome={() => navigate('/home')}
|
||||
onReset={resetConversation}
|
||||
/>
|
||||
|
||||
<main
|
||||
ref={scrollRef}
|
||||
className={cn(
|
||||
'styled-scrollbar min-h-0 flex-1 overflow-y-auto overflow-x-hidden rounded-[1.5rem] border border-border/50 bg-card/85 px-5 py-5 shadow-sm',
|
||||
'[&_[data-streamdown="code-block"]]:!max-w-full [&_[data-streamdown="table-wrapper"]]:!max-w-full [&_[data-streamdown="code-block"]]:overflow-x-auto [&_[data-streamdown="table-wrapper"]]:overflow-x-auto',
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground text-sm">
|
||||
Loading conversation...
|
||||
</div>
|
||||
) : turns.length === 0 ? (
|
||||
<EmptyConversationState agentName={agentName} />
|
||||
) : (
|
||||
<div className="w-full space-y-4">
|
||||
{turns.map((turn, index) => (
|
||||
<ConversationMessage
|
||||
key={turn.id}
|
||||
turn={turn}
|
||||
streaming={streaming && index === turns.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<div className="w-full flex-shrink-0">
|
||||
<ConversationInput
|
||||
variant="conversation"
|
||||
agents={agents}
|
||||
selectedAgentId={agentId}
|
||||
selectedAgentId={resolvedAgentId}
|
||||
onSelectAgent={handleSelectAgent}
|
||||
onSend={(text) => {
|
||||
void send(text)
|
||||
}}
|
||||
onCreateAgent={() => navigate(createAgentPath)}
|
||||
onCreateAgent={() => navigate('/agents')}
|
||||
streaming={streaming}
|
||||
disabled={disabled}
|
||||
disabled={status?.status !== 'running'}
|
||||
status={status?.status}
|
||||
placeholder={`Message ${agentName}...`}
|
||||
/>
|
||||
@@ -321,76 +192,3 @@ function AgentConversationController({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface AgentCommandConversationProps {
|
||||
variant?: 'command' | 'page'
|
||||
backPath?: string
|
||||
agentPathPrefix?: string
|
||||
createAgentPath?: string
|
||||
}
|
||||
|
||||
export const AgentCommandConversation: FC<AgentCommandConversationProps> = ({
|
||||
variant = 'command',
|
||||
backPath = '/home',
|
||||
agentPathPrefix = '/home/agents',
|
||||
createAgentPath = '/agents',
|
||||
}) => {
|
||||
const { agentId } = useParams<{ agentId: string }>()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const { status, agents } = useAgentCommandData()
|
||||
const shouldRedirectHome = !agentId
|
||||
const resolvedAgentId = agentId ?? ''
|
||||
const agent = agents.find((entry) => entry.agentId === resolvedAgentId)
|
||||
const agentName = agent?.name || resolvedAgentId || 'Agent'
|
||||
const agentMeta = getModelDisplayName(agent?.model) ?? 'OpenClaw agent'
|
||||
const initialMessage = searchParams.get('q')
|
||||
const isPageVariant = variant === 'page'
|
||||
const backLabel = isPageVariant ? 'Back to agents' : 'Back to home'
|
||||
|
||||
if (shouldRedirectHome) {
|
||||
return <Navigate to="/home" replace />
|
||||
}
|
||||
|
||||
const handleSelectAgent = (entry: AgentEntry) => {
|
||||
navigate(`${agentPathPrefix}/${entry.agentId}`)
|
||||
}
|
||||
|
||||
const statusCopy = getConversationStatusCopy(status?.status)
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 overflow-hidden bg-background md:pl-[theme(spacing.14)]">
|
||||
<div className="mx-auto grid h-full w-full max-w-[1480px] lg:grid-cols-[288px_minmax(0,1fr)] lg:grid-rows-[3.5rem_minmax(0,1fr)]">
|
||||
<AgentRailHeader onGoHome={() => navigate(backPath)} />
|
||||
|
||||
<ConversationHeader
|
||||
agentName={agentName}
|
||||
agentMeta={agentMeta}
|
||||
status={statusCopy}
|
||||
backLabel={backLabel}
|
||||
backTarget={isPageVariant ? 'page' : 'home'}
|
||||
onGoHome={() => navigate(backPath)}
|
||||
/>
|
||||
|
||||
<AgentRailList
|
||||
activeAgentId={resolvedAgentId}
|
||||
agents={agents}
|
||||
onSelectAgent={handleSelectAgent}
|
||||
/>
|
||||
|
||||
<AgentConversationController
|
||||
key={resolvedAgentId}
|
||||
agentId={resolvedAgentId}
|
||||
agents={agents}
|
||||
status={status}
|
||||
initialMessage={initialMessage}
|
||||
onInitialMessageConsumed={() =>
|
||||
setSearchParams({}, { replace: true })
|
||||
}
|
||||
agentPathPrefix={agentPathPrefix}
|
||||
createAgentPath={createAgentPath}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { ArrowRight, Bot, Plus, Settings2 } from 'lucide-react'
|
||||
import { ArrowRight } from 'lucide-react'
|
||||
import { type FC, useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import type { AgentEntry } from '@/entrypoints/app/agents/useOpenClaw'
|
||||
import { ImportDataHint } from '@/entrypoints/newtab/index/ImportDataHint'
|
||||
import { NewTabBranding } from '@/entrypoints/newtab/index/NewTabBranding'
|
||||
import { NewTabTip } from '@/entrypoints/newtab/index/NewTabTip'
|
||||
import { ScheduleResults } from '@/entrypoints/newtab/index/ScheduleResults'
|
||||
import { SignInHint } from '@/entrypoints/newtab/index/SignInHint'
|
||||
import { TopSites } from '@/entrypoints/newtab/index/TopSites'
|
||||
import { useActiveHint } from '@/entrypoints/newtab/index/useActiveHint'
|
||||
import { AgentCardDock } from './AgentCardDock'
|
||||
import { useAgentCommandData } from './agent-command-layout'
|
||||
@@ -19,19 +22,13 @@ function AgentCommandSetupState({
|
||||
onOpenAgents: () => void
|
||||
}) {
|
||||
return (
|
||||
<Card className="border-border/60 bg-card/90 shadow-sm">
|
||||
<CardContent className="flex flex-col items-center gap-4 p-8 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
|
||||
<Bot className="size-5" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="font-semibold text-lg">Set up your first agent</h2>
|
||||
<p className="max-w-md text-muted-foreground text-sm leading-6">
|
||||
Connect OpenClaw and create an agent before using the new tab as
|
||||
your workspace.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={onOpenAgents} className="gap-2 rounded-xl">
|
||||
<Card className="border-border/60 bg-card/85 shadow-sm">
|
||||
<CardContent className="flex flex-col items-center gap-4 p-6 text-center">
|
||||
<p className="max-w-xl text-muted-foreground text-sm">
|
||||
Set up OpenClaw agents to turn your new tab into an agent command
|
||||
center.
|
||||
</p>
|
||||
<Button onClick={onOpenAgents} className="gap-2">
|
||||
Open Agent Setup
|
||||
<ArrowRight className="size-4" />
|
||||
</Button>
|
||||
@@ -42,19 +39,13 @@ function AgentCommandSetupState({
|
||||
|
||||
function EmptyAgentsState({ onOpenAgents }: { onOpenAgents: () => void }) {
|
||||
return (
|
||||
<Card className="border-border/60 bg-card/90 shadow-sm">
|
||||
<CardContent className="flex flex-col items-center gap-4 p-8 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
|
||||
<Plus className="size-5" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="font-semibold text-lg">No agents yet</h2>
|
||||
<p className="max-w-md text-muted-foreground text-sm leading-6">
|
||||
Create an agent to start using BrowserOS as an agent-first new tab.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={onOpenAgents} className="rounded-xl">
|
||||
Create agent
|
||||
<Card className="border-border/60 bg-card/85 shadow-sm">
|
||||
<CardContent className="flex flex-col items-center gap-4 p-6 text-center">
|
||||
<p className="max-w-xl text-muted-foreground text-sm">
|
||||
OpenClaw is running, but you do not have any agents yet.
|
||||
</p>
|
||||
<Button variant="outline" onClick={onOpenAgents}>
|
||||
Create your first agent
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -67,19 +58,13 @@ function OpenClawUnavailableState({
|
||||
onOpenAgents: () => void
|
||||
}) {
|
||||
return (
|
||||
<Card className="border-border/60 bg-card/90 shadow-sm">
|
||||
<CardContent className="flex flex-col items-center gap-4 p-8 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
|
||||
<Settings2 className="size-5" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="font-semibold text-lg">OpenClaw is unavailable</h2>
|
||||
<p className="max-w-md text-muted-foreground text-sm leading-6">
|
||||
Review your agent setup to restart the gateway or reconnect the
|
||||
local service.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={onOpenAgents} className="gap-2 rounded-xl">
|
||||
<Card className="border-border/60 bg-card/85 shadow-sm">
|
||||
<CardContent className="flex flex-col items-center gap-4 p-6 text-center">
|
||||
<p className="max-w-xl text-muted-foreground text-sm">
|
||||
OpenClaw is unavailable right now. Open the Agents page to restart the
|
||||
gateway or review setup.
|
||||
</p>
|
||||
<Button onClick={onOpenAgents} className="gap-2">
|
||||
Open Agent Setup
|
||||
<ArrowRight className="size-4" />
|
||||
</Button>
|
||||
@@ -88,54 +73,18 @@ function OpenClawUnavailableState({
|
||||
)
|
||||
}
|
||||
|
||||
function RecentThreads({
|
||||
activeAgentId,
|
||||
agents,
|
||||
onOpenAgents,
|
||||
onSelectAgent,
|
||||
}: {
|
||||
activeAgentId?: string | null
|
||||
agents: ReturnType<typeof useAgentCardData>
|
||||
onOpenAgents: () => void
|
||||
onSelectAgent: (agentId: string) => void
|
||||
}) {
|
||||
if (agents.length === 0) return null
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="font-semibold text-base">Recent agents</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Continue from where you left off.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onOpenAgents}
|
||||
className="rounded-xl"
|
||||
size="sm"
|
||||
>
|
||||
Manage agents
|
||||
</Button>
|
||||
</div>
|
||||
<AgentCardDock
|
||||
agents={agents}
|
||||
activeAgentId={activeAgentId ?? undefined}
|
||||
onSelectAgent={onSelectAgent}
|
||||
onCreateAgent={onOpenAgents}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export const AgentCommandHome: FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const activeHint = useActiveHint()
|
||||
const { status, agents } = useAgentCommandData()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null)
|
||||
const cardData = useAgentCardData(agents, status?.status)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (agents.length === 0) {
|
||||
if (selectedAgentId) {
|
||||
@@ -168,65 +117,62 @@ export const AgentCommandHome: FC = () => {
|
||||
openClawStatus !== 'running' &&
|
||||
openClawStatus !== 'uninitialized' &&
|
||||
cardData.length === 0
|
||||
const selectedCard =
|
||||
cardData.find((agent) => agent.agentId === selectedAgentId) ?? cardData[0]
|
||||
|
||||
return (
|
||||
<div className="min-h-full px-4 py-6">
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-8">
|
||||
<div className="pt-[max(25vh,16px)]">
|
||||
<div className="relative w-full space-y-8 md:w-3xl">
|
||||
<NewTabBranding />
|
||||
|
||||
<ConversationInput
|
||||
variant="home"
|
||||
agents={agents}
|
||||
selectedAgentId={selectedAgentId}
|
||||
onSelectAgent={handleSelectAgent}
|
||||
onSend={handleSend}
|
||||
onCreateAgent={() => navigate('/agents')}
|
||||
streaming={false}
|
||||
disabled={status?.status !== 'running'}
|
||||
status={status?.status}
|
||||
placeholder={
|
||||
status?.status === 'running'
|
||||
? undefined
|
||||
: 'OpenClaw is not running...'
|
||||
}
|
||||
/>
|
||||
|
||||
{mounted ? <NewTabTip /> : null}
|
||||
|
||||
{isSetup ? (
|
||||
shouldShowUnavailableState ? (
|
||||
<OpenClawUnavailableState
|
||||
onOpenAgents={() => navigate('/agents')}
|
||||
/>
|
||||
) : cardData.length > 0 ? (
|
||||
<>
|
||||
<div className="flex flex-col items-center gap-5 pt-[max(10vh,24px)] text-center">
|
||||
<div className="space-y-3">
|
||||
<h1 className="font-semibold text-[clamp(2rem,4vw,3.25rem)] leading-tight tracking-tight">
|
||||
What should your agent work on next?
|
||||
</h1>
|
||||
<p className="mx-auto max-w-2xl text-muted-foreground text-sm leading-6">
|
||||
Start with a task, continue a thread, or switch to another
|
||||
agent without leaving the new tab.
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold text-base">Agents</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Pick up where your agents left off.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-3xl">
|
||||
<ConversationInput
|
||||
variant="home"
|
||||
agents={agents}
|
||||
selectedAgentId={selectedAgentId}
|
||||
onSelectAgent={handleSelectAgent}
|
||||
onSend={handleSend}
|
||||
onCreateAgent={() => navigate('/agents')}
|
||||
streaming={false}
|
||||
disabled={status?.status !== 'running'}
|
||||
status={status?.status}
|
||||
placeholder={
|
||||
status?.status === 'running'
|
||||
? `Ask ${selectedCard?.name ?? 'your agent'} to handle a task...`
|
||||
: 'OpenClaw is not running...'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<RecentThreads
|
||||
activeAgentId={selectedAgentId}
|
||||
<AgentCardDock
|
||||
agents={cardData}
|
||||
onOpenAgents={() => navigate('/agents')}
|
||||
activeAgentId={selectedAgentId ?? undefined}
|
||||
onSelectAgent={(agentId) => navigate(`/home/agents/${agentId}`)}
|
||||
onCreateAgent={() => navigate('/agents')}
|
||||
/>
|
||||
</>
|
||||
</section>
|
||||
) : (
|
||||
<EmptyAgentsState onOpenAgents={() => navigate('/agents')} />
|
||||
)
|
||||
) : (
|
||||
<AgentCommandSetupState onOpenAgents={() => navigate('/agents')} />
|
||||
)}
|
||||
|
||||
{mounted ? <TopSites /> : null}
|
||||
{mounted ? <ScheduleResults /> : null}
|
||||
</div>
|
||||
|
||||
{activeHint === 'signin' ? <SignInHint /> : null}
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
import { Bot, Loader2, RefreshCw } from 'lucide-react'
|
||||
import { type FC, useEffect, useRef } from 'react'
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationScrollButton,
|
||||
} from '@/components/ai-elements/conversation'
|
||||
import type { AgentConversationTurn } from '@/lib/agent-conversations/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ClawChatMessage } from './ClawChatMessage'
|
||||
import { ConversationMessage } from './ConversationMessage'
|
||||
import type { ClawChatMessage as ClawChatMessageModel } from './claw-chat-types'
|
||||
|
||||
interface ClawChatProps {
|
||||
agentName: string
|
||||
historyMessages: ClawChatMessageModel[]
|
||||
turns: AgentConversationTurn[]
|
||||
streaming: boolean
|
||||
isInitialLoading: boolean
|
||||
error: Error | null
|
||||
hasNextPage: boolean
|
||||
isFetchingNextPage: boolean
|
||||
onFetchNextPage: () => void
|
||||
onRetry: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
function EmptyConversationState({ agentName }: { agentName: string }) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center px-6 py-12">
|
||||
<div className="max-w-md text-center">
|
||||
<div className="mx-auto flex size-14 items-center justify-center rounded-3xl bg-muted text-muted-foreground">
|
||||
<Bot className="size-6" />
|
||||
</div>
|
||||
<h2 className="mt-5 font-semibold text-xl">{agentName}</h2>
|
||||
<p className="mt-2 text-muted-foreground text-sm leading-6">
|
||||
Ask {agentName} to start a task.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LoadingConversationState() {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center gap-2 text-muted-foreground text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading conversation...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConversationErrorState({
|
||||
message,
|
||||
onRetry,
|
||||
}: {
|
||||
message: string
|
||||
onRetry: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center px-6 py-12">
|
||||
<div className="max-w-md rounded-2xl border border-border/60 bg-card px-5 py-4 text-center shadow-sm">
|
||||
<p className="text-sm">{message}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="mt-3 inline-flex items-center gap-2 rounded-lg border border-border/60 px-3 py-1.5 font-medium text-muted-foreground text-xs transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ClawChat: FC<ClawChatProps> = ({
|
||||
agentName,
|
||||
historyMessages,
|
||||
turns,
|
||||
streaming,
|
||||
isInitialLoading,
|
||||
error,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
onFetchNextPage,
|
||||
onRetry,
|
||||
className,
|
||||
}) => {
|
||||
const topSentinelRef = useRef<HTMLDivElement>(null)
|
||||
const onFetchNextPageRef = useRef(onFetchNextPage)
|
||||
onFetchNextPageRef.current = onFetchNextPage
|
||||
const hasMessages = historyMessages.length > 0 || turns.length > 0
|
||||
|
||||
useEffect(() => {
|
||||
const sentinel = topSentinelRef.current
|
||||
if (!sentinel) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const [entry] = entries
|
||||
if (!entry?.isIntersecting || !hasNextPage || isFetchingNextPage) {
|
||||
return
|
||||
}
|
||||
|
||||
onFetchNextPageRef.current()
|
||||
},
|
||||
{
|
||||
root: null,
|
||||
rootMargin: '160px 0px 0px 0px',
|
||||
threshold: 0,
|
||||
},
|
||||
)
|
||||
|
||||
observer.observe(sentinel)
|
||||
return () => observer.disconnect()
|
||||
}, [hasNextPage, isFetchingNextPage])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex min-h-0 flex-1 flex-col overflow-hidden', className)}
|
||||
>
|
||||
<Conversation
|
||||
className={cn(
|
||||
'bg-background',
|
||||
'[&_[data-streamdown="code-block"]]:!w-full [&_[data-streamdown="code-block"]]:!max-w-full [&_[data-streamdown="table-wrapper"]]:!w-full [&_[data-streamdown="table-wrapper"]]:!max-w-full [&_[data-streamdown="code-block"]]:overflow-x-auto [&_[data-streamdown="table-wrapper"]]:overflow-x-auto',
|
||||
)}
|
||||
>
|
||||
<ConversationContent className="min-h-full px-5 py-5">
|
||||
{isInitialLoading ? (
|
||||
<LoadingConversationState />
|
||||
) : error && !hasMessages ? (
|
||||
<ConversationErrorState message={error.message} onRetry={onRetry} />
|
||||
) : !hasMessages ? (
|
||||
<EmptyConversationState agentName={agentName} />
|
||||
) : (
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-3">
|
||||
<div ref={topSentinelRef} aria-hidden="true" className="h-px" />
|
||||
{isFetchingNextPage ? (
|
||||
<div className="flex justify-center py-2 text-muted-foreground text-xs">
|
||||
<Loader2 className="mr-2 size-3.5 animate-spin" />
|
||||
Loading older messages...
|
||||
</div>
|
||||
) : null}
|
||||
{!hasNextPage && historyMessages.length > 0 ? (
|
||||
<div className="py-1 text-center text-muted-foreground text-xs">
|
||||
Start of conversation
|
||||
</div>
|
||||
) : null}
|
||||
{historyMessages.map((message) => (
|
||||
<ClawChatMessage key={message.id} message={message} />
|
||||
))}
|
||||
{turns.map((turn, index) => (
|
||||
<ConversationMessage
|
||||
key={turn.id}
|
||||
turn={turn}
|
||||
streaming={streaming && index === turns.length - 1}
|
||||
/>
|
||||
))}
|
||||
{error ? (
|
||||
<div className="rounded-xl border border-border/60 bg-card px-4 py-3 text-muted-foreground text-sm">
|
||||
{error.message}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { CheckCircle2, Loader2, XCircle } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import {
|
||||
Message,
|
||||
MessageContent,
|
||||
MessageResponse,
|
||||
} from '@/components/ai-elements/message'
|
||||
import {
|
||||
Reasoning,
|
||||
ReasoningContent,
|
||||
ReasoningTrigger,
|
||||
} from '@/components/ai-elements/reasoning'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ClawChatMessage as ClawChatMessageType } from './claw-chat-types'
|
||||
|
||||
interface ClawChatMessageProps {
|
||||
message: ClawChatMessageType
|
||||
}
|
||||
|
||||
export const ClawChatMessage: FC<ClawChatMessageProps> = ({ message }) => (
|
||||
<Message
|
||||
from={message.role}
|
||||
className="max-w-full group-[.is-user]:max-w-[80%]"
|
||||
>
|
||||
<MessageContent className="max-w-full overflow-hidden group-[.is-assistant]:w-full group-[.is-user]:max-w-full">
|
||||
{message.parts.map((part, index) => {
|
||||
const key = `${message.id}-part-${index}`
|
||||
|
||||
switch (part.type) {
|
||||
case 'text':
|
||||
return (
|
||||
<MessageResponse
|
||||
key={key}
|
||||
className={cn(
|
||||
'max-w-full overflow-hidden break-words',
|
||||
'[&_[data-streamdown="code-block"]]:!w-full [&_[data-streamdown="code-block"]]:!max-w-full [&_[data-streamdown="code-block"]]:overflow-x-auto',
|
||||
'[&_[data-streamdown="table-wrapper"]]:!w-full [&_[data-streamdown="table-wrapper"]]:!max-w-full [&_[data-streamdown="table-wrapper"]]:overflow-x-auto',
|
||||
'[&_table]:w-max [&_table]:min-w-full',
|
||||
)}
|
||||
>
|
||||
{part.text}
|
||||
</MessageResponse>
|
||||
)
|
||||
|
||||
case 'reasoning':
|
||||
return (
|
||||
<Reasoning key={key} className="w-full" defaultOpen={false}>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>{part.text}</ReasoningContent>
|
||||
</Reasoning>
|
||||
)
|
||||
|
||||
case 'tool-call':
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
{part.status === 'running' || part.status === 'pending' ? (
|
||||
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
|
||||
) : null}
|
||||
{part.status === 'completed' ? (
|
||||
<CheckCircle2 className="size-3.5 text-green-500" />
|
||||
) : null}
|
||||
{part.status === 'failed' ? (
|
||||
<XCircle className="size-3.5 text-destructive" />
|
||||
) : null}
|
||||
<span className="font-mono text-xs">{part.name}</span>
|
||||
{part.error ? (
|
||||
<span className="ml-auto text-destructive text-xs">
|
||||
{part.error}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'meta':
|
||||
return (
|
||||
<div key={key} className="text-muted-foreground text-xs">
|
||||
{part.label}: {part.value}
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
)
|
||||
@@ -8,19 +8,11 @@ import {
|
||||
Mic,
|
||||
Square,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
type FC,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { type FC, type ReactNode, useEffect, useState } from 'react'
|
||||
import { AppSelector } from '@/components/elements/AppSelector'
|
||||
import { TabPickerPopover } from '@/components/elements/tab-picker-popover'
|
||||
import { WorkspaceSelector } from '@/components/elements/workspace-selector'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import type { AgentEntry } from '@/entrypoints/app/agents/useOpenClaw'
|
||||
import { McpServerIcon } from '@/entrypoints/app/connect-mcp/McpServerIcon'
|
||||
import { useGetUserMCPIntegrations } from '@/entrypoints/app/connect-mcp/useGetUserMCPIntegrations'
|
||||
@@ -154,7 +146,7 @@ function ContextControls({
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between border-border/40 border-t px-4 py-2.5">
|
||||
<div className="flex items-center justify-between border-border/50 border-t px-5 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
{showAgentSelector ? (
|
||||
<AgentSelector
|
||||
@@ -242,7 +234,7 @@ function ContextControls({
|
||||
|
||||
function HomeShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[1.55rem] border border-border/60 bg-card/95 shadow-sm">
|
||||
<div className="overflow-hidden rounded-[1.5rem] border border-border/60 bg-card/95 shadow-sm backdrop-blur">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
@@ -250,7 +242,7 @@ function HomeShell({ children }: { children: ReactNode }) {
|
||||
|
||||
function ConversationShell({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-[1.35rem] border border-border/50 bg-background/95 shadow-[0_10px_30px_rgba(15,23,42,0.06)] backdrop-blur-md">
|
||||
<div className="overflow-hidden rounded-[1.5rem] border border-border/60 bg-card/95 shadow-sm backdrop-blur">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
@@ -270,27 +262,10 @@ export const ConversationInput: FC<ConversationInputProps> = ({
|
||||
}) => {
|
||||
const [input, setInput] = useState('')
|
||||
const [selectedTabs, setSelectedTabs] = useState<chrome.tabs.Tab[]>([])
|
||||
const [isExpandedDraft, setIsExpandedDraft] = useState(false)
|
||||
const voice = useVoiceInput()
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const selectedAgent = agents.find(
|
||||
(agent) => agent.agentId === selectedAgentId,
|
||||
)
|
||||
const isConversation = variant === 'conversation'
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const element = textareaRef.current
|
||||
if (!element) return
|
||||
|
||||
const maxHeight = isConversation ? 176 : 100
|
||||
const collapsedHeight = isConversation ? 56 : 72
|
||||
element.style.height = '0px'
|
||||
const nextHeight = Math.min(element.scrollHeight, maxHeight)
|
||||
element.style.height = `${nextHeight}px`
|
||||
element.style.overflowY =
|
||||
element.scrollHeight > maxHeight ? 'auto' : 'hidden'
|
||||
setIsExpandedDraft(nextHeight > collapsedHeight)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (voice.transcript && !voice.isTranscribing) {
|
||||
@@ -321,43 +296,26 @@ export const ConversationInput: FC<ConversationInputProps> = ({
|
||||
|
||||
return (
|
||||
<Shell>
|
||||
<div
|
||||
className={cn(
|
||||
'flex gap-3',
|
||||
variant === 'home' ? 'px-4 py-3' : 'px-4 py-3',
|
||||
isExpandedDraft ? 'items-end' : 'items-center',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 px-5 py-4">
|
||||
<BotInputIcon variant={variant} />
|
||||
<div className="flex-1">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}}
|
||||
rows={1}
|
||||
placeholder={
|
||||
voice.isTranscribing
|
||||
? 'Transcribing...'
|
||||
: (placeholder ??
|
||||
`Message ${selectedAgent?.name ?? 'agent'}...`)
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(event) => setInput(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
disabled={disabled || voice.isTranscribing}
|
||||
className={cn(
|
||||
'resize-none border-none bg-transparent px-0 text-[15px] shadow-none focus-visible:ring-0',
|
||||
'[field-sizing:fixed]',
|
||||
variant === 'home'
|
||||
? 'min-h-[40px] py-2 leading-6'
|
||||
: 'min-h-[40px] py-2 leading-6',
|
||||
'placeholder:text-muted-foreground/80',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
}}
|
||||
placeholder={
|
||||
voice.isTranscribing
|
||||
? 'Transcribing...'
|
||||
: (placeholder ?? `Message ${selectedAgent?.name ?? 'agent'}...`)
|
||||
}
|
||||
disabled={disabled || voice.isTranscribing}
|
||||
className="flex-1 border-none bg-transparent text-base text-foreground outline-none placeholder:text-muted-foreground disabled:opacity-60"
|
||||
/>
|
||||
<VoiceButton
|
||||
isRecording={voice.isRecording}
|
||||
isTranscribing={voice.isTranscribing}
|
||||
@@ -403,8 +361,8 @@ function BotInputIcon({ variant }: { variant: 'home' | 'conversation' }) {
|
||||
className={cn(
|
||||
'flex items-center justify-center text-[var(--accent-orange)]',
|
||||
variant === 'home'
|
||||
? 'h-8 w-8 rounded-lg bg-[var(--accent-orange)]/10'
|
||||
: 'h-8 w-8 rounded-lg bg-[var(--accent-orange)]/10',
|
||||
? 'h-10 w-10 rounded-xl bg-[var(--accent-orange)]/10'
|
||||
: 'h-9 w-9 rounded-xl bg-[var(--accent-orange)]/12',
|
||||
)}
|
||||
>
|
||||
<Bot className="h-4 w-4" />
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import {
|
||||
type AgentHistoryPageResponse,
|
||||
type BrowserOSChatHistoryItem,
|
||||
buildChatHistoryFromClawMessages,
|
||||
flattenHistoryPages,
|
||||
mapHistoryItemToClawMessage,
|
||||
} from './claw-chat-types'
|
||||
|
||||
function historyItem(
|
||||
overrides: Partial<BrowserOSChatHistoryItem>,
|
||||
): BrowserOSChatHistoryItem {
|
||||
return {
|
||||
id: 'session-1:0',
|
||||
role: 'user',
|
||||
text: 'Hello',
|
||||
timestamp: 1000,
|
||||
messageSeq: 0,
|
||||
sessionKey: 'session-1',
|
||||
source: 'user-chat',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function page(items: BrowserOSChatHistoryItem[]): AgentHistoryPageResponse {
|
||||
return {
|
||||
agentId: 'main',
|
||||
sessionKey: 'session-1',
|
||||
session: null,
|
||||
items,
|
||||
page: {
|
||||
hasMore: false,
|
||||
limit: 50,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('claw-chat-types', () => {
|
||||
it('maps backend history items into text-first ClawChat messages', () => {
|
||||
const message = mapHistoryItemToClawMessage(
|
||||
historyItem({
|
||||
id: 'session-1:1',
|
||||
role: 'assistant',
|
||||
text: 'Hi there',
|
||||
messageSeq: 1,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(message).toEqual({
|
||||
id: 'session-1:1',
|
||||
role: 'assistant',
|
||||
sessionKey: 'session-1',
|
||||
timestamp: 1000,
|
||||
source: 'user-chat',
|
||||
messageSeq: 1,
|
||||
status: 'historical',
|
||||
parts: [{ type: 'text', text: 'Hi there' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('flattens paginated history into oldest-to-newest render order', () => {
|
||||
const messages = flattenHistoryPages([
|
||||
page([
|
||||
historyItem({
|
||||
id: 'session-1:2',
|
||||
role: 'user',
|
||||
text: 'newer',
|
||||
timestamp: 3000,
|
||||
messageSeq: 2,
|
||||
}),
|
||||
]),
|
||||
page([
|
||||
historyItem({
|
||||
id: 'session-1:0',
|
||||
role: 'user',
|
||||
text: 'older',
|
||||
timestamp: 1000,
|
||||
messageSeq: 0,
|
||||
}),
|
||||
historyItem({
|
||||
id: 'session-1:1',
|
||||
role: 'assistant',
|
||||
text: 'middle',
|
||||
timestamp: 2000,
|
||||
messageSeq: 1,
|
||||
}),
|
||||
]),
|
||||
])
|
||||
|
||||
expect(messages.map((message) => message.id)).toEqual([
|
||||
'session-1:0',
|
||||
'session-1:1',
|
||||
'session-1:2',
|
||||
])
|
||||
})
|
||||
|
||||
it('builds OpenClaw chat history from text message parts only', () => {
|
||||
const history = buildChatHistoryFromClawMessages([
|
||||
{
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
sessionKey: 'session-1',
|
||||
parts: [{ type: 'text', text: ' User request ' }],
|
||||
},
|
||||
{
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
sessionKey: 'session-1',
|
||||
parts: [
|
||||
{ type: 'reasoning', text: 'private reasoning' },
|
||||
{ type: 'text', text: 'Assistant answer' },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
expect(history).toEqual([
|
||||
{ role: 'user', content: 'User request' },
|
||||
{ role: 'assistant', content: 'Assistant answer' },
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,125 +0,0 @@
|
||||
import type { OpenClawChatHistoryMessage } from '@/entrypoints/app/agents/useOpenClaw'
|
||||
|
||||
export type ClawChatRole = 'user' | 'assistant'
|
||||
|
||||
export type ClawChatSource = 'user-chat' | 'cron' | 'hook' | 'channel' | 'other'
|
||||
|
||||
export interface BrowserOSOpenClawSession {
|
||||
key: string
|
||||
updatedAt: number
|
||||
sessionId: string
|
||||
agentId: string
|
||||
kind: string
|
||||
source: ClawChatSource
|
||||
status?: string
|
||||
totalTokens?: number
|
||||
model?: string
|
||||
modelProvider?: string
|
||||
}
|
||||
|
||||
export interface AgentSessionResponse {
|
||||
agentId: string
|
||||
exists: boolean
|
||||
sessionKey: string | null
|
||||
session: BrowserOSOpenClawSession | null
|
||||
}
|
||||
|
||||
export interface BrowserOSChatHistoryItem {
|
||||
id: string
|
||||
role: ClawChatRole
|
||||
text: string
|
||||
timestamp?: number
|
||||
messageSeq: number
|
||||
sessionKey: string
|
||||
source: ClawChatSource
|
||||
}
|
||||
|
||||
export interface AgentHistoryPageResponse {
|
||||
agentId: string
|
||||
sessionKey: string | null
|
||||
session: BrowserOSOpenClawSession | null
|
||||
items: BrowserOSChatHistoryItem[]
|
||||
page: {
|
||||
cursor?: string
|
||||
hasMore: boolean
|
||||
limit: number
|
||||
}
|
||||
}
|
||||
|
||||
export type ClawChatMessageStatus =
|
||||
| 'historical'
|
||||
| 'sending'
|
||||
| 'streaming'
|
||||
| 'error'
|
||||
|
||||
export type ClawChatMessagePart =
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'reasoning'; text: string; duration?: number }
|
||||
| {
|
||||
type: 'tool-call'
|
||||
name: string
|
||||
status: 'pending' | 'running' | 'completed' | 'failed'
|
||||
input?: unknown
|
||||
output?: unknown
|
||||
error?: string
|
||||
}
|
||||
| { type: 'meta'; label: string; value: string }
|
||||
|
||||
export interface ClawChatMessage {
|
||||
id: string
|
||||
role: ClawChatRole
|
||||
sessionKey: string
|
||||
timestamp?: number
|
||||
source?: ClawChatSource
|
||||
messageSeq?: number
|
||||
status?: ClawChatMessageStatus
|
||||
parts: ClawChatMessagePart[]
|
||||
}
|
||||
|
||||
export function mapHistoryItemToClawMessage(
|
||||
item: BrowserOSChatHistoryItem,
|
||||
): ClawChatMessage {
|
||||
return {
|
||||
id: item.id,
|
||||
role: item.role,
|
||||
sessionKey: item.sessionKey,
|
||||
timestamp: item.timestamp,
|
||||
source: item.source,
|
||||
messageSeq: item.messageSeq,
|
||||
status: 'historical',
|
||||
parts: [{ type: 'text', text: item.text }],
|
||||
}
|
||||
}
|
||||
|
||||
export function flattenHistoryPages(
|
||||
pages: AgentHistoryPageResponse[],
|
||||
): ClawChatMessage[] {
|
||||
return pages
|
||||
.flatMap((page) => page.items)
|
||||
.sort((a, b) => {
|
||||
if (a.timestamp != null && b.timestamp != null) {
|
||||
return a.timestamp - b.timestamp
|
||||
}
|
||||
return a.messageSeq - b.messageSeq
|
||||
})
|
||||
.map(mapHistoryItemToClawMessage)
|
||||
}
|
||||
|
||||
export function buildChatHistoryFromClawMessages(
|
||||
messages: ClawChatMessage[],
|
||||
): OpenClawChatHistoryMessage[] {
|
||||
return messages
|
||||
.map((message) => {
|
||||
const content = message.parts
|
||||
.filter((part): part is { type: 'text'; text: string } => {
|
||||
return part.type === 'text' && part.text.trim().length > 0
|
||||
})
|
||||
.map((part) => part.text.trim())
|
||||
.join('\n\n')
|
||||
|
||||
return content ? { role: message.role, content } : null
|
||||
})
|
||||
.filter((message): message is OpenClawChatHistoryMessage =>
|
||||
Boolean(message),
|
||||
)
|
||||
}
|
||||
@@ -1,45 +1,51 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
chatWithAgent,
|
||||
type OpenClawChatHistoryMessage,
|
||||
type OpenClawStreamEvent,
|
||||
} from '@/entrypoints/app/agents/useOpenClaw'
|
||||
import {
|
||||
getLatestConversation,
|
||||
saveConversation,
|
||||
} from '@/lib/agent-conversations/storage'
|
||||
import type {
|
||||
AgentConversation,
|
||||
AgentConversationTurn,
|
||||
AssistantPart,
|
||||
} from '@/lib/agent-conversations/types'
|
||||
import { consumeSSEStream } from '@/lib/sse'
|
||||
|
||||
interface UseAgentConversationOptions {
|
||||
sessionKey?: string | null
|
||||
history?: OpenClawChatHistoryMessage[]
|
||||
onSessionKeyChange?: (sessionKey: string) => void
|
||||
}
|
||||
|
||||
export function useAgentConversation(
|
||||
agentId: string,
|
||||
options: UseAgentConversationOptions = {},
|
||||
) {
|
||||
export function useAgentConversation(agentId: string, agentName: string) {
|
||||
const [turns, setTurns] = useState<AgentConversationTurn[]>([])
|
||||
const [streaming, setStreaming] = useState(false)
|
||||
const sessionKeyRef = useRef(options.sessionKey ?? '')
|
||||
const historyRef = useRef<OpenClawChatHistoryMessage[]>(options.history ?? [])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const sessionKeyRef = useRef('')
|
||||
const textAccRef = useRef('')
|
||||
const thinkAccRef = useRef('')
|
||||
const streamAbortRef = useRef<AbortController | null>(null)
|
||||
const onSessionKeyChangeRef = useRef(options.onSessionKeyChange)
|
||||
|
||||
useEffect(() => {
|
||||
sessionKeyRef.current = options.sessionKey ?? ''
|
||||
}, [options.sessionKey])
|
||||
|
||||
useEffect(() => {
|
||||
historyRef.current = options.history ?? []
|
||||
}, [options.history])
|
||||
|
||||
useEffect(() => {
|
||||
onSessionKeyChangeRef.current = options.onSessionKeyChange
|
||||
}, [options.onSessionKeyChange])
|
||||
let active = true
|
||||
getLatestConversation(agentId)
|
||||
.then((conv) => {
|
||||
if (!active) return
|
||||
if (conv) {
|
||||
setTurns(conv.turns)
|
||||
sessionKeyRef.current = conv.sessionKey
|
||||
} else {
|
||||
sessionKeyRef.current = crypto.randomUUID()
|
||||
}
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) {
|
||||
sessionKeyRef.current = crypto.randomUUID()
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [agentId])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -47,6 +53,18 @@ export function useAgentConversation(
|
||||
}
|
||||
}, [])
|
||||
|
||||
const persistTurns = (updatedTurns: AgentConversationTurn[]) => {
|
||||
const conv: AgentConversation = {
|
||||
agentId,
|
||||
agentName,
|
||||
sessionKey: sessionKeyRef.current,
|
||||
turns: updatedTurns,
|
||||
createdAt: updatedTurns[0]?.timestamp ?? Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
saveConversation(conv).catch(() => {})
|
||||
}
|
||||
|
||||
const updateCurrentTurnParts = (
|
||||
updater: (parts: AssistantPart[]) => AssistantPart[],
|
||||
) => {
|
||||
@@ -146,7 +164,9 @@ export function useAgentConversation(
|
||||
setTurns((prev) => {
|
||||
const last = prev[prev.length - 1]
|
||||
if (!last) return prev
|
||||
return [...prev.slice(0, -1), { ...last, done: true }]
|
||||
const updated = [...prev.slice(0, -1), { ...last, done: true }]
|
||||
persistTurns(updated)
|
||||
return updated
|
||||
})
|
||||
break
|
||||
}
|
||||
@@ -186,15 +206,9 @@ export function useAgentConversation(
|
||||
const response = await chatWithAgent(
|
||||
agentId,
|
||||
text.trim(),
|
||||
sessionKeyRef.current || undefined,
|
||||
historyRef.current,
|
||||
sessionKeyRef.current,
|
||||
abortController.signal,
|
||||
)
|
||||
const responseSessionKey = response.headers.get('X-Session-Key')
|
||||
if (responseSessionKey) {
|
||||
sessionKeyRef.current = responseSessionKey
|
||||
onSessionKeyChangeRef.current?.(responseSessionKey)
|
||||
}
|
||||
if (!response.ok) {
|
||||
const err = await response.text()
|
||||
updateCurrentTurnParts((parts) => [
|
||||
@@ -228,11 +242,13 @@ export function useAgentConversation(
|
||||
streamAbortRef.current = null
|
||||
setTurns([])
|
||||
setStreaming(false)
|
||||
sessionKeyRef.current = crypto.randomUUID()
|
||||
}
|
||||
|
||||
return {
|
||||
turns,
|
||||
streaming,
|
||||
loading,
|
||||
sessionKey: sessionKeyRef.current,
|
||||
send,
|
||||
resetConversation,
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
|
||||
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||
import type {
|
||||
AgentHistoryPageResponse,
|
||||
AgentSessionResponse,
|
||||
} from './claw-chat-types'
|
||||
|
||||
export const CLAW_CHAT_QUERY_KEYS = {
|
||||
session: 'claw-agent-session',
|
||||
history: 'claw-agent-history',
|
||||
} as const
|
||||
|
||||
async function fetchClawJson<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url)
|
||||
|
||||
if (!response.ok) {
|
||||
let message = `Request failed with status ${response.status}`
|
||||
try {
|
||||
const body = (await response.json()) as { error?: string }
|
||||
if (body.error) message = body.error
|
||||
} catch {}
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
function buildClawUrl(baseUrl: string, path: string): URL {
|
||||
return new URL(`/claw${path}`, baseUrl)
|
||||
}
|
||||
|
||||
export function useClawAgentSession(agentId: string) {
|
||||
const {
|
||||
baseUrl,
|
||||
isLoading: urlLoading,
|
||||
error: urlError,
|
||||
} = useAgentServerUrl()
|
||||
|
||||
const query = useQuery<AgentSessionResponse, Error>({
|
||||
queryKey: [CLAW_CHAT_QUERY_KEYS.session, baseUrl, agentId],
|
||||
queryFn: () => {
|
||||
const url = buildClawUrl(baseUrl as string, `/agents/${agentId}/session`)
|
||||
return fetchClawJson<AgentSessionResponse>(url.toString())
|
||||
},
|
||||
enabled: Boolean(baseUrl) && !urlLoading && Boolean(agentId),
|
||||
})
|
||||
|
||||
return {
|
||||
...query,
|
||||
error: query.error ?? urlError,
|
||||
isLoading: query.isLoading || urlLoading,
|
||||
}
|
||||
}
|
||||
|
||||
export function useClawChatHistory({
|
||||
agentId,
|
||||
sessionKey,
|
||||
enabled,
|
||||
limit = 50,
|
||||
}: {
|
||||
agentId: string
|
||||
sessionKey: string | null
|
||||
enabled: boolean
|
||||
limit?: number
|
||||
}) {
|
||||
const {
|
||||
baseUrl,
|
||||
isLoading: urlLoading,
|
||||
error: urlError,
|
||||
} = useAgentServerUrl()
|
||||
|
||||
const query = useInfiniteQuery<AgentHistoryPageResponse, Error>({
|
||||
queryKey: [CLAW_CHAT_QUERY_KEYS.history, baseUrl, agentId, sessionKey],
|
||||
initialPageParam: undefined as string | undefined,
|
||||
queryFn: ({ pageParam }) => {
|
||||
const url = buildClawUrl(baseUrl as string, `/agents/${agentId}/history`)
|
||||
url.searchParams.set('limit', String(limit))
|
||||
|
||||
if (sessionKey) {
|
||||
url.searchParams.set('sessionKey', sessionKey)
|
||||
}
|
||||
if (typeof pageParam === 'string' && pageParam) {
|
||||
url.searchParams.set('cursor', pageParam)
|
||||
}
|
||||
|
||||
return fetchClawJson<AgentHistoryPageResponse>(url.toString())
|
||||
},
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.page.hasMore ? lastPage.page.cursor : undefined,
|
||||
enabled:
|
||||
enabled &&
|
||||
Boolean(baseUrl) &&
|
||||
!urlLoading &&
|
||||
Boolean(agentId) &&
|
||||
Boolean(sessionKey),
|
||||
})
|
||||
|
||||
return {
|
||||
...query,
|
||||
error: query.error ?? urlError,
|
||||
isLoading: query.isLoading || urlLoading,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
Bot,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
Send,
|
||||
XCircle,
|
||||
} from 'lucide-react'
|
||||
import { type FC, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Message,
|
||||
MessageContent,
|
||||
MessageResponse,
|
||||
} from '@/components/ai-elements/message'
|
||||
import {
|
||||
Reasoning,
|
||||
ReasoningContent,
|
||||
ReasoningTrigger,
|
||||
} from '@/components/ai-elements/reasoning'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { consumeSSEStream } from '@/lib/sse'
|
||||
import { chatWithAgent, type OpenClawStreamEvent } from './useOpenClaw'
|
||||
|
||||
interface ToolEntry {
|
||||
id: string
|
||||
name: string
|
||||
status: 'running' | 'completed' | 'error'
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
type AssistantPart =
|
||||
| { kind: 'thinking'; text: string; done: boolean }
|
||||
| { kind: 'tool-batch'; tools: ToolEntry[] }
|
||||
| { kind: 'text'; text: string }
|
||||
|
||||
interface ChatTurn {
|
||||
id: string
|
||||
userText: string
|
||||
parts: AssistantPart[]
|
||||
done: boolean
|
||||
}
|
||||
|
||||
interface AgentChatProps {
|
||||
agentId: string
|
||||
agentName: string
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
export const AgentChat: FC<AgentChatProps> = ({
|
||||
agentId,
|
||||
agentName,
|
||||
onBack,
|
||||
}) => {
|
||||
const [turns, setTurns] = useState<ChatTurn[]>([])
|
||||
const [input, setInput] = useState('')
|
||||
const [streaming, setStreaming] = useState(false)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const sessionKeyRef = useRef(crypto.randomUUID())
|
||||
const streamAbortRef = useRef<AbortController | null>(null)
|
||||
|
||||
const textAccRef = useRef('')
|
||||
const thinkAccRef = useRef('')
|
||||
|
||||
const scrollToBottom = () => {
|
||||
scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight)
|
||||
}
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll on every turns change
|
||||
useEffect(() => {
|
||||
scrollToBottom()
|
||||
}, [turns])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
streamAbortRef.current?.abort()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const updateCurrentTurnParts = (
|
||||
updater: (parts: AssistantPart[]) => AssistantPart[],
|
||||
) => {
|
||||
setTurns((prev) => {
|
||||
const last = prev[prev.length - 1]
|
||||
if (!last) return prev
|
||||
return [...prev.slice(0, -1), { ...last, parts: updater(last.parts) }]
|
||||
})
|
||||
}
|
||||
|
||||
const processStreamEvent = (event: OpenClawStreamEvent) => {
|
||||
switch (event.type) {
|
||||
case 'text-delta': {
|
||||
const delta = (event.data.text as string) ?? ''
|
||||
textAccRef.current += delta
|
||||
const text = textAccRef.current
|
||||
updateCurrentTurnParts((parts) => {
|
||||
const last = parts[parts.length - 1]
|
||||
if (last?.kind === 'text') {
|
||||
return [...parts.slice(0, -1), { ...last, text }]
|
||||
}
|
||||
return [...parts, { kind: 'text', text }]
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'thinking': {
|
||||
const delta = (event.data.text as string) ?? ''
|
||||
thinkAccRef.current += delta
|
||||
const text = thinkAccRef.current
|
||||
updateCurrentTurnParts((parts) => {
|
||||
const idx = parts.findIndex((p) => p.kind === 'thinking' && !p.done)
|
||||
if (idx >= 0) {
|
||||
return [
|
||||
...parts.slice(0, idx),
|
||||
{ ...parts[idx], text, done: false },
|
||||
...parts.slice(idx + 1),
|
||||
]
|
||||
}
|
||||
return [...parts, { kind: 'thinking', text, done: false }]
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'tool-start': {
|
||||
const tool: ToolEntry = {
|
||||
id: (event.data.toolCallId as string) ?? crypto.randomUUID(),
|
||||
name: (event.data.toolName as string) ?? 'unknown',
|
||||
status: 'running',
|
||||
}
|
||||
updateCurrentTurnParts((parts) => {
|
||||
const last = parts[parts.length - 1]
|
||||
if (last?.kind === 'tool-batch') {
|
||||
return [
|
||||
...parts.slice(0, -1),
|
||||
{ ...last, tools: [...last.tools, tool] },
|
||||
]
|
||||
}
|
||||
return [...parts, { kind: 'tool-batch', tools: [tool] }]
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'tool-end': {
|
||||
const toolId = event.data.toolCallId as string
|
||||
const status =
|
||||
(event.data.status as string) === 'error' ? 'error' : 'completed'
|
||||
const durationMs = event.data.durationMs as number | undefined
|
||||
updateCurrentTurnParts((parts) => {
|
||||
for (let i = parts.length - 1; i >= 0; i--) {
|
||||
const part = parts[i]
|
||||
if (
|
||||
part.kind === 'tool-batch' &&
|
||||
part.tools.some((t) => t.id === toolId)
|
||||
) {
|
||||
const updatedTools = part.tools.map((t) =>
|
||||
t.id === toolId
|
||||
? {
|
||||
...t,
|
||||
status: status as ToolEntry['status'],
|
||||
durationMs,
|
||||
}
|
||||
: t,
|
||||
)
|
||||
return [
|
||||
...parts.slice(0, i),
|
||||
{ ...part, tools: updatedTools },
|
||||
...parts.slice(i + 1),
|
||||
]
|
||||
}
|
||||
}
|
||||
return parts
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'done': {
|
||||
updateCurrentTurnParts((parts) =>
|
||||
parts.map((part) =>
|
||||
part.kind === 'thinking' ? { ...part, done: true } : part,
|
||||
),
|
||||
)
|
||||
setTurns((prev) => {
|
||||
const last = prev[prev.length - 1]
|
||||
if (!last) return prev
|
||||
return [...prev.slice(0, -1), { ...last, done: true }]
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'error': {
|
||||
const msg =
|
||||
(event.data.message as string) ??
|
||||
(event.data.error as string) ??
|
||||
'Unknown error'
|
||||
updateCurrentTurnParts((parts) => [
|
||||
...parts,
|
||||
{ kind: 'text', text: `Error: ${msg}` },
|
||||
])
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleSend = async () => {
|
||||
const text = input.trim()
|
||||
if (!text || streaming) return
|
||||
|
||||
const turn: ChatTurn = {
|
||||
id: crypto.randomUUID(),
|
||||
userText: text,
|
||||
parts: [],
|
||||
done: false,
|
||||
}
|
||||
setTurns((prev) => [...prev, turn])
|
||||
setInput('')
|
||||
setStreaming(true)
|
||||
|
||||
textAccRef.current = ''
|
||||
thinkAccRef.current = ''
|
||||
const abortController = new AbortController()
|
||||
streamAbortRef.current = abortController
|
||||
|
||||
try {
|
||||
const response = await chatWithAgent(
|
||||
agentId,
|
||||
text,
|
||||
sessionKeyRef.current,
|
||||
abortController.signal,
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text()
|
||||
updateCurrentTurnParts((parts) => [
|
||||
...parts,
|
||||
{ kind: 'text', text: `Error: ${err}` },
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
await consumeSSEStream(
|
||||
response,
|
||||
processStreamEvent,
|
||||
abortController.signal,
|
||||
)
|
||||
} catch (err) {
|
||||
if (abortController.signal.aborted) return
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
updateCurrentTurnParts((parts) => [
|
||||
...parts,
|
||||
{ kind: 'text', text: `Error: ${msg}` },
|
||||
])
|
||||
} finally {
|
||||
if (streamAbortRef.current === abortController) {
|
||||
streamAbortRef.current = null
|
||||
}
|
||||
setStreaming(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-4rem)] flex-col">
|
||||
<div className="flex items-center gap-2 border-b px-4 py-3">
|
||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<h2 className="font-semibold text-lg">{agentName}</h2>
|
||||
</div>
|
||||
|
||||
<div ref={scrollRef} className="flex-1 space-y-4 overflow-y-auto p-4">
|
||||
{turns.map((turn) => (
|
||||
<div key={turn.id} className="space-y-3">
|
||||
{/* User message */}
|
||||
<Message from="user">
|
||||
<MessageContent>
|
||||
<pre className="whitespace-pre-wrap font-sans text-sm">
|
||||
{turn.userText}
|
||||
</pre>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
|
||||
{/* Assistant response — all parts grouped */}
|
||||
{turn.parts.length > 0 && (
|
||||
<Message from="assistant">
|
||||
<MessageContent>
|
||||
{turn.parts.map((part, i) => {
|
||||
const key = `${turn.id}-part-${i}`
|
||||
|
||||
switch (part.kind) {
|
||||
case 'thinking':
|
||||
return (
|
||||
<Reasoning
|
||||
key={key}
|
||||
className="w-full"
|
||||
isStreaming={!part.done}
|
||||
defaultOpen={!part.done}
|
||||
>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>{part.text}</ReasoningContent>
|
||||
</Reasoning>
|
||||
)
|
||||
|
||||
case 'tool-batch':
|
||||
return (
|
||||
<div key={key} className="w-full space-y-1">
|
||||
{part.tools.map((tool) => (
|
||||
<div
|
||||
key={tool.id}
|
||||
className="flex items-center gap-2 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
{tool.status === 'running' && (
|
||||
<Loader2 className="size-3.5 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
{tool.status === 'completed' && (
|
||||
<CheckCircle2 className="size-3.5 text-green-500" />
|
||||
)}
|
||||
{tool.status === 'error' && (
|
||||
<XCircle className="size-3.5 text-destructive" />
|
||||
)}
|
||||
<span className="font-mono text-xs">
|
||||
{tool.name}
|
||||
</span>
|
||||
{tool.durationMs != null && (
|
||||
<span className="ml-auto text-muted-foreground text-xs">
|
||||
{(tool.durationMs / 1000).toFixed(1)}s
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'text':
|
||||
return (
|
||||
<MessageResponse key={key}>
|
||||
{part.text}
|
||||
</MessageResponse>
|
||||
)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
)}
|
||||
|
||||
{/* Streaming indicator when waiting for first part */}
|
||||
{!turn.done && turn.parts.length === 0 && streaming && (
|
||||
<div className="flex gap-2">
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-[var(--accent-orange)] text-white">
|
||||
<Bot className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1 rounded-xl rounded-tl-none border border-border/50 bg-card px-3 py-2.5 shadow-sm">
|
||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[var(--accent-orange)] [animation-delay:-0.3s]" />
|
||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[var(--accent-orange)] [animation-delay:-0.15s]" />
|
||||
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-[var(--accent-orange)]" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t p-4">
|
||||
<div className="flex gap-2">
|
||||
<Textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}}
|
||||
placeholder="Send a message..."
|
||||
className="min-h-[44px] resize-none"
|
||||
rows={1}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || streaming}
|
||||
size="icon"
|
||||
>
|
||||
{streaming ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,16 +5,14 @@ import {
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { ArrowLeft, Check, Copy } from 'lucide-react'
|
||||
import { type FC, useEffect, useRef, useState } from 'react'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { type FC, useEffect, useRef } from 'react'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { getAgentServerUrl } from '@/lib/browseros/helpers'
|
||||
|
||||
interface AgentTerminalProps {
|
||||
onBack: () => void
|
||||
initialCommand?: string
|
||||
onSessionExit?: () => void
|
||||
}
|
||||
|
||||
type TerminalServerMessage =
|
||||
@@ -38,22 +36,26 @@ function resolveCssColor(variableName: string): string {
|
||||
return color
|
||||
}
|
||||
|
||||
function withAlpha(color: string, alpha: number): string {
|
||||
const channels = color.match(/[\d.]+/g)
|
||||
if (!channels || channels.length < 3) return color
|
||||
const [red, green, blue] = channels
|
||||
return `rgb(${red} ${green} ${blue} / ${alpha})`
|
||||
}
|
||||
|
||||
function createTerminalTheme() {
|
||||
const isDark = document.documentElement.classList.contains('dark')
|
||||
const background = resolveCssColor('--background')
|
||||
const foreground = resolveCssColor('--foreground')
|
||||
const muted = resolveCssColor('--muted-foreground')
|
||||
const accent = resolveCssColor('--accent-orange')
|
||||
|
||||
return {
|
||||
background,
|
||||
foreground,
|
||||
cursor: foreground,
|
||||
cursorAccent: background,
|
||||
// Solid terminal-standard selection colors. Deriving from a CSS var
|
||||
// with alpha composed against the background produced near-white
|
||||
// rectangles on light mode, making selection invisible.
|
||||
selectionBackground: isDark ? '#3a4463' : '#b4d4f4',
|
||||
selectionInactiveBackground: isDark ? '#2b3348' : '#d9e5f3',
|
||||
selectionBackground: withAlpha(accent, isDark ? 0.3 : 0.2),
|
||||
selectionForeground: foreground,
|
||||
black: isDark ? '#16131a' : '#1f1b22',
|
||||
red: isDark ? '#ef8c7c' : '#c25544',
|
||||
@@ -116,38 +118,8 @@ function parseTerminalMessage(data: unknown): TerminalServerMessage | null {
|
||||
return null
|
||||
}
|
||||
|
||||
export const AgentTerminal: FC<AgentTerminalProps> = ({
|
||||
onBack,
|
||||
initialCommand,
|
||||
onSessionExit,
|
||||
}) => {
|
||||
export const AgentTerminal: FC<AgentTerminalProps> = ({ onBack }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const terminalRef = useRef<Terminal | null>(null)
|
||||
// Refs keep the mount-once effect from tearing down the PTY when the
|
||||
// parent re-renders with new inline callbacks.
|
||||
const initialCommandRef = useRef(initialCommand)
|
||||
const onSessionExitRef = useRef(onSessionExit)
|
||||
initialCommandRef.current = initialCommand
|
||||
onSessionExitRef.current = onSessionExit
|
||||
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
// Copy the current xterm selection to the browser clipboard. No-op
|
||||
// if nothing is selected — users who want the whole buffer can
|
||||
// Cmd+A first. Uses the browser clipboard, not the container's, so
|
||||
// it works even when the running TUI has mouse tracking enabled
|
||||
// (Opt+drag forces a selection regardless, see terminal config).
|
||||
const handleCopy = async (): Promise<void> => {
|
||||
const text = terminalRef.current?.getSelection()
|
||||
if (!text) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
window.setTimeout(() => setCopied(false), 1500)
|
||||
} catch {
|
||||
// clipboard permission denied or unavailable — swallow, user will retry
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
@@ -160,34 +132,6 @@ export const AgentTerminal: FC<AgentTerminalProps> = ({
|
||||
lineHeight: 1.25,
|
||||
scrollback: 8000,
|
||||
theme: createTerminalTheme(),
|
||||
// Opt+click+drag forces a native text selection even when the
|
||||
// running TUI has mouse-tracking enabled (xterm would otherwise
|
||||
// forward every click to the app and selection wouldn't work).
|
||||
macOptionClickForcesSelection: true,
|
||||
})
|
||||
terminalRef.current = terminal
|
||||
|
||||
// Cmd+A → select all, Cmd+C → copy selection via the browser
|
||||
// clipboard. Return false so xterm doesn't also forward the keys
|
||||
// to the running program.
|
||||
terminal.attachCustomKeyEventHandler((event) => {
|
||||
if (event.type !== 'keydown') return true
|
||||
const isMac = navigator.platform.toUpperCase().includes('MAC')
|
||||
const mod = isMac ? event.metaKey : event.ctrlKey
|
||||
if (!mod) return true
|
||||
const key = event.key.toLowerCase()
|
||||
if (key === 'a') {
|
||||
terminal.selectAll()
|
||||
return false
|
||||
}
|
||||
if (key === 'c') {
|
||||
const sel = terminal.getSelection()
|
||||
if (sel) {
|
||||
void navigator.clipboard.writeText(sel)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const fitAddon = new FitAddon()
|
||||
@@ -195,12 +139,6 @@ export const AgentTerminal: FC<AgentTerminalProps> = ({
|
||||
terminal.loadAddon(new WebLinksAddon())
|
||||
terminal.open(containerRef.current)
|
||||
|
||||
// React 18 StrictMode double-invokes effects in dev. Everything
|
||||
// async inside this effect is scoped to an AbortController; the
|
||||
// cleanup aborts it and any pending awaits bail out, so we never
|
||||
// leak a second live WebSocket or duplicate xterm listeners.
|
||||
const ac = new AbortController()
|
||||
const cleanups: Array<() => void> = []
|
||||
let ws: WebSocket | null = null
|
||||
let sawExit = false
|
||||
|
||||
@@ -221,28 +159,17 @@ export const AgentTerminal: FC<AgentTerminalProps> = ({
|
||||
sendMessage({ type: 'resize', cols, rows })
|
||||
}
|
||||
|
||||
const connect = async (): Promise<void> => {
|
||||
const connect = async () => {
|
||||
const baseUrl = await getAgentServerUrl()
|
||||
if (ac.signal.aborted) return
|
||||
const wsUrl = new URL('/terminal/ws', baseUrl)
|
||||
wsUrl.protocol = wsUrl.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
|
||||
ws = new WebSocket(wsUrl)
|
||||
// If the effect was cleaned up between the await above and now,
|
||||
// close the socket we just opened and bail.
|
||||
if (ac.signal.aborted) {
|
||||
ws.close()
|
||||
ws = null
|
||||
return
|
||||
}
|
||||
cleanups.push(() => ws?.close())
|
||||
|
||||
ws.onopen = () => {
|
||||
fitAddon.fit()
|
||||
terminal.focus()
|
||||
sendResize()
|
||||
const cmd = initialCommandRef.current
|
||||
if (cmd) sendMessage({ type: 'input', data: `${cmd}\n` })
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
@@ -258,7 +185,6 @@ export const AgentTerminal: FC<AgentTerminalProps> = ({
|
||||
terminal.write(
|
||||
`\r\n\x1b[90m[session ended with exit ${message.exitCode}]\x1b[0m\r\n`,
|
||||
)
|
||||
onSessionExitRef.current?.()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,41 +200,49 @@ export const AgentTerminal: FC<AgentTerminalProps> = ({
|
||||
const inputDisposable = terminal.onData((data) => {
|
||||
sendMessage({ type: 'input', data })
|
||||
})
|
||||
|
||||
const resizeDisposable = terminal.onResize(({ cols, rows }) => {
|
||||
sendResize(cols, rows)
|
||||
})
|
||||
cleanups.push(() => inputDisposable.dispose())
|
||||
cleanups.push(() => resizeDisposable.dispose())
|
||||
|
||||
return () => {
|
||||
inputDisposable.dispose()
|
||||
resizeDisposable.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
void connect()
|
||||
let disposeSocketBindings: (() => void) | undefined
|
||||
void connect().then((disposeBindings) => {
|
||||
disposeSocketBindings = disposeBindings
|
||||
})
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
fitAddon.fit()
|
||||
sendResize()
|
||||
})
|
||||
resizeObserver.observe(containerRef.current)
|
||||
cleanups.push(() => resizeObserver.disconnect())
|
||||
|
||||
const themeObserver = new MutationObserver(() => applyTheme())
|
||||
const themeObserver = new MutationObserver(() => {
|
||||
applyTheme()
|
||||
})
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class'],
|
||||
})
|
||||
cleanups.push(() => themeObserver.disconnect())
|
||||
|
||||
return () => {
|
||||
ac.abort()
|
||||
for (const dispose of cleanups) dispose()
|
||||
resizeObserver.disconnect()
|
||||
themeObserver.disconnect()
|
||||
disposeSocketBindings?.()
|
||||
ws?.close()
|
||||
terminal.dispose()
|
||||
terminalRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100dvh-10rem)] min-h-[32rem] w-full flex-col py-2 sm:min-h-[42rem] sm:py-4">
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-border bg-card shadow-sm">
|
||||
<div className="flex items-center justify-between gap-3 border-border border-b px-4 py-3 sm:px-6">
|
||||
<div className="flex items-center gap-3 border-border border-b px-4 py-3 sm:px-6">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Button variant="ghost" size="icon" onClick={onBack}>
|
||||
<ArrowLeft className="size-4" />
|
||||
@@ -322,14 +256,6 @@ export const AgentTerminal: FC<AgentTerminalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleCopy}>
|
||||
{copied ? (
|
||||
<Check className="mr-1 size-3.5" />
|
||||
) : (
|
||||
<Copy className="mr-1 size-3.5" />
|
||||
)}
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 p-4 sm:p-6">
|
||||
@@ -343,7 +269,7 @@ export const AgentTerminal: FC<AgentTerminalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 cursor-text px-4 py-4 sm:px-5 sm:py-5">
|
||||
<div className="min-h-0 flex-1 px-4 py-4 sm:px-5 sm:py-5">
|
||||
<div ref={containerRef} className="h-full w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,185 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { CheckCircle2, Loader2, Terminal, TriangleAlert } from 'lucide-react'
|
||||
import type { FC } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||
|
||||
export interface OpenClawCliProvider {
|
||||
id: string
|
||||
displayName: string
|
||||
description: string
|
||||
models: readonly string[]
|
||||
authLoginCommand: string
|
||||
}
|
||||
|
||||
export interface OpenClawCliProviderAuthStatus {
|
||||
installed: boolean
|
||||
loggedIn: boolean
|
||||
accountLabel?: string
|
||||
subscriptionLabel?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface OpenClawCliProviderOption {
|
||||
id: string
|
||||
type: string
|
||||
name: string
|
||||
modelId: string
|
||||
}
|
||||
|
||||
const CLAUDE_CLI_PROVIDER: OpenClawCliProvider = {
|
||||
id: 'claude-cli',
|
||||
displayName: 'Anthropic Claude CLI',
|
||||
description: 'Uses your Claude.ai subscription via the Claude Code CLI',
|
||||
models: ['claude-sonnet-4-6', 'claude-opus-4-6', 'claude-haiku-4-5'],
|
||||
authLoginCommand: 'claude /login',
|
||||
}
|
||||
|
||||
export const OPENCLAW_CLI_PROVIDERS: readonly OpenClawCliProvider[] = [
|
||||
CLAUDE_CLI_PROVIDER,
|
||||
]
|
||||
|
||||
export function findOpenClawCliProviderById(
|
||||
id: string,
|
||||
): OpenClawCliProvider | undefined {
|
||||
return OPENCLAW_CLI_PROVIDERS.find((provider) => provider.id === id)
|
||||
}
|
||||
|
||||
export function buildOpenClawCliProviderOptions(): OpenClawCliProviderOption[] {
|
||||
return OPENCLAW_CLI_PROVIDERS.flatMap((provider) =>
|
||||
provider.models.map((modelId) => ({
|
||||
id: `${provider.id}/${modelId}`,
|
||||
type: provider.id,
|
||||
name: provider.displayName,
|
||||
modelId,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
async function fetchCliProviderAuthStatus(
|
||||
baseUrl: string,
|
||||
providerId: string,
|
||||
): Promise<OpenClawCliProviderAuthStatus> {
|
||||
const res = await fetch(`${baseUrl}/claw/providers/${providerId}/auth-status`)
|
||||
if (!res.ok) {
|
||||
let message = `Auth status request failed (${res.status})`
|
||||
try {
|
||||
const body = (await res.json()) as { error?: string }
|
||||
if (body.error) message = body.error
|
||||
} catch {}
|
||||
throw new Error(message)
|
||||
}
|
||||
return res.json() as Promise<OpenClawCliProviderAuthStatus>
|
||||
}
|
||||
|
||||
export function useOpenClawCliProviderAuthStatus(
|
||||
providerId: string,
|
||||
enabled: boolean,
|
||||
) {
|
||||
const { baseUrl, isLoading: urlLoading } = useAgentServerUrl()
|
||||
return useQuery<OpenClawCliProviderAuthStatus, Error>({
|
||||
queryKey: ['openclaw-cli-auth', baseUrl, providerId],
|
||||
queryFn: () => fetchCliProviderAuthStatus(baseUrl as string, providerId),
|
||||
enabled: !!baseUrl && !urlLoading && enabled,
|
||||
refetchInterval: enabled ? 2000 : false,
|
||||
})
|
||||
}
|
||||
|
||||
interface OpenClawCliProviderStatusPanelProps {
|
||||
provider: OpenClawCliProvider
|
||||
status: OpenClawCliProviderAuthStatus | undefined
|
||||
loading: boolean
|
||||
fetchError: Error | null
|
||||
onConnect: () => void
|
||||
}
|
||||
|
||||
export const OpenClawCliProviderStatusPanel: FC<
|
||||
OpenClawCliProviderStatusPanelProps
|
||||
> = ({ provider, status, loading, fetchError, onConnect }) => {
|
||||
// Initial fetch (no data yet).
|
||||
if (loading && !status) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/30 px-3 py-2 text-sm">
|
||||
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||
<span className="text-muted-foreground">
|
||||
Checking {provider.displayName} status…
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (fetchError) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm">
|
||||
<TriangleAlert className="mt-0.5 size-4 text-destructive" />
|
||||
<div>
|
||||
<div className="font-medium text-destructive">
|
||||
Could not read {provider.displayName} status
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{fetchError.message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!status) return null
|
||||
|
||||
// Install failed or binary missing.
|
||||
if (!status.installed) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 rounded-md border border-amber-500/40 bg-amber-500/5 px-3 py-2 text-sm">
|
||||
<TriangleAlert className="mt-0.5 size-4 text-amber-600" />
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{provider.displayName} not installed
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
The gateway will try to install it on the next restart. If this
|
||||
persists, check your network and the gateway logs.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Happy path.
|
||||
if (status.loggedIn) {
|
||||
const identityBits = [
|
||||
status.accountLabel,
|
||||
status.subscriptionLabel ? `(${status.subscriptionLabel})` : null,
|
||||
].filter(Boolean)
|
||||
const identity = identityBits.length > 0 ? identityBits.join(' ') : 'Ready'
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-md border border-emerald-500/40 bg-emerald-500/5 px-3 py-2 text-sm">
|
||||
<CheckCircle2 className="size-4 text-emerald-600" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium">Connected to {provider.displayName}</div>
|
||||
<div className="truncate text-muted-foreground text-xs">
|
||||
{identity}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Installed but not logged in.
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-md border border-border bg-muted/30 px-3 py-3 text-sm">
|
||||
<div>
|
||||
<div className="font-medium">{provider.displayName} not set up</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{provider.description}
|
||||
</div>
|
||||
{status.error && (
|
||||
<div className="mt-1 text-destructive text-xs">{status.error}</div>
|
||||
)}
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={onConnect} className="w-fit">
|
||||
<Terminal className="mr-1 size-4" />
|
||||
Connect {provider.displayName}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { LlmProviderConfig, ProviderType } from '@/lib/llm-providers/types'
|
||||
|
||||
const OPENCLAW_SUPPORTED_PROVIDER_TYPES: ProviderType[] = [
|
||||
'openrouter',
|
||||
'openai',
|
||||
'openai-compatible',
|
||||
'anthropic',
|
||||
'moonshot',
|
||||
]
|
||||
|
||||
export function isOpenClawSupportedProviderType(
|
||||
providerType: ProviderType,
|
||||
): boolean {
|
||||
return OPENCLAW_SUPPORTED_PROVIDER_TYPES.includes(providerType)
|
||||
}
|
||||
|
||||
export function getOpenClawSupportedProviders(
|
||||
providers: LlmProviderConfig[],
|
||||
): LlmProviderConfig[] {
|
||||
return providers.filter(
|
||||
(provider) =>
|
||||
!!provider.apiKey && isOpenClawSupportedProviderType(provider.type),
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
import type {
|
||||
BrowserOSAgentRoleId,
|
||||
BrowserOSCustomRoleInput,
|
||||
} from '@browseros/shared/types/role-aware-agents'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { getAgentServerUrl } from '@/lib/browseros/helpers'
|
||||
import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders'
|
||||
@@ -7,6 +11,27 @@ export interface AgentEntry {
|
||||
name: string
|
||||
workspace: string
|
||||
model?: unknown
|
||||
role?: {
|
||||
roleSource: 'builtin' | 'custom'
|
||||
roleId?: BrowserOSAgentRoleId
|
||||
roleName: string
|
||||
shortDescription: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface RoleTemplateSummary {
|
||||
id: BrowserOSAgentRoleId
|
||||
name: string
|
||||
shortDescription: string
|
||||
longDescription: string
|
||||
recommendedApps: string[]
|
||||
defaultAgentName: string
|
||||
boundaries: Array<{
|
||||
key: string
|
||||
label: string
|
||||
description: string
|
||||
defaultMode: 'allow' | 'ask' | 'block'
|
||||
}>
|
||||
}
|
||||
|
||||
export interface OpenClawStatus {
|
||||
@@ -36,6 +61,8 @@ export interface OpenClawStatus {
|
||||
|
||||
export interface OpenClawAgentMutationInput {
|
||||
name: string
|
||||
roleId?: BrowserOSAgentRoleId
|
||||
customRole?: BrowserOSCustomRoleInput
|
||||
providerType?: string
|
||||
providerName?: string
|
||||
baseUrl?: string
|
||||
@@ -59,15 +86,9 @@ export function getModelDisplayName(model: unknown): string | undefined {
|
||||
export const OPENCLAW_QUERY_KEYS = {
|
||||
status: 'openclaw-status',
|
||||
agents: 'openclaw-agents',
|
||||
roles: 'openclaw-roles',
|
||||
} as const
|
||||
|
||||
export type GatewayLifecycleAction =
|
||||
| 'setup'
|
||||
| 'start'
|
||||
| 'stop'
|
||||
| 'restart'
|
||||
| 'reconnect'
|
||||
|
||||
async function clawFetch<T>(
|
||||
baseUrl: string,
|
||||
path: string,
|
||||
@@ -96,6 +117,16 @@ async function fetchOpenClawAgents(baseUrl: string): Promise<AgentEntry[]> {
|
||||
return data.agents ?? []
|
||||
}
|
||||
|
||||
async function fetchOpenClawRoles(
|
||||
baseUrl: string,
|
||||
): Promise<RoleTemplateSummary[]> {
|
||||
const data = await clawFetch<{ roles: RoleTemplateSummary[] }>(
|
||||
baseUrl,
|
||||
'/roles',
|
||||
)
|
||||
return data.roles ?? []
|
||||
}
|
||||
|
||||
async function invalidateOpenClawQueries(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
): Promise<void> {
|
||||
@@ -148,6 +179,28 @@ export function useOpenClawAgents(enabled = true) {
|
||||
}
|
||||
}
|
||||
|
||||
export function useOpenClawRoles() {
|
||||
const {
|
||||
baseUrl,
|
||||
isLoading: urlLoading,
|
||||
error: urlError,
|
||||
} = useAgentServerUrl()
|
||||
|
||||
const query = useQuery<RoleTemplateSummary[], Error>({
|
||||
queryKey: [OPENCLAW_QUERY_KEYS.roles, baseUrl],
|
||||
queryFn: () => fetchOpenClawRoles(baseUrl as string),
|
||||
enabled: !!baseUrl && !urlLoading,
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
return {
|
||||
roles: query.data ?? [],
|
||||
loading: query.isLoading || urlLoading,
|
||||
error: query.error ?? urlError,
|
||||
refetch: query.refetch,
|
||||
}
|
||||
}
|
||||
|
||||
export function useOpenClawMutations() {
|
||||
const { baseUrl, isLoading: urlLoading } = useAgentServerUrl()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -225,13 +278,6 @@ export function useOpenClawMutations() {
|
||||
onSuccess,
|
||||
})
|
||||
|
||||
let pendingGatewayAction: GatewayLifecycleAction | null = null
|
||||
if (setupMutation.isPending) pendingGatewayAction = 'setup'
|
||||
else if (restartMutation.isPending) pendingGatewayAction = 'restart'
|
||||
else if (stopMutation.isPending) pendingGatewayAction = 'stop'
|
||||
else if (startMutation.isPending) pendingGatewayAction = 'start'
|
||||
else if (reconnectMutation.isPending) pendingGatewayAction = 'reconnect'
|
||||
|
||||
return {
|
||||
setupOpenClaw: setupMutation.mutateAsync,
|
||||
createAgent: createMutation.mutateAsync,
|
||||
@@ -252,7 +298,6 @@ export function useOpenClawMutations() {
|
||||
creating: createMutation.isPending,
|
||||
deleting: deleteMutation.isPending,
|
||||
reconnecting: reconnectMutation.isPending,
|
||||
pendingGatewayAction,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,60 +314,17 @@ export interface OpenClawStreamEvent {
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface OpenClawChatHistoryMessage {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
interface ChatHistoryTurnLike {
|
||||
userText: string
|
||||
parts: Array<{ kind: string; text?: string }>
|
||||
}
|
||||
|
||||
export function buildChatHistoryFromTurns(
|
||||
turns: ChatHistoryTurnLike[],
|
||||
): OpenClawChatHistoryMessage[] {
|
||||
const messages: OpenClawChatHistoryMessage[] = []
|
||||
|
||||
for (const turn of turns) {
|
||||
const userText = turn.userText.trim()
|
||||
if (userText) {
|
||||
messages.push({ role: 'user', content: userText })
|
||||
}
|
||||
|
||||
const assistantText = turn.parts
|
||||
.filter(
|
||||
(
|
||||
part,
|
||||
): part is {
|
||||
kind: 'text'
|
||||
text: string
|
||||
} => part.kind === 'text' && typeof part.text === 'string',
|
||||
)
|
||||
.map((part) => part.text.trim())
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
|
||||
if (assistantText) {
|
||||
messages.push({ role: 'assistant', content: assistantText })
|
||||
}
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
export async function chatWithAgent(
|
||||
agentId: string,
|
||||
message: string,
|
||||
sessionKey?: string,
|
||||
history: OpenClawChatHistoryMessage[] = [],
|
||||
signal?: AbortSignal,
|
||||
): Promise<Response> {
|
||||
const baseUrl = await getAgentServerUrl()
|
||||
return fetch(`${baseUrl}/claw/agents/${agentId}/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message, sessionKey, history }),
|
||||
body: JSON.stringify({ message, sessionKey }),
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@ describe('route-utils', () => {
|
||||
expect(shouldUseChatSession('/home/chat')).toBe(true)
|
||||
})
|
||||
|
||||
it('hides the focus grid on full-screen routes', () => {
|
||||
expect(shouldHideFocusGrid('/home')).toBe(true)
|
||||
it('keeps the focus grid on home while hiding it on dedicated full-screen routes', () => {
|
||||
expect(shouldHideFocusGrid('/home')).toBe(false)
|
||||
expect(shouldHideFocusGrid('/home/agents/main')).toBe(true)
|
||||
expect(shouldHideFocusGrid('/home/chat')).toBe(true)
|
||||
expect(shouldHideFocusGrid('/home/skills')).toBe(true)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
const HIDE_FOCUS_GRID_PATHS = new Set([
|
||||
'/home',
|
||||
'/home/soul',
|
||||
'/home/memory',
|
||||
'/home/skills',
|
||||
|
||||
@@ -7,9 +7,8 @@ import { PRODUCT_WEB_HOST } from './lib/constants/productWebHost'
|
||||
// biome-ignore lint/style/noProcessEnv: build config file needs env access
|
||||
const env = process.env
|
||||
|
||||
const apiUrl = new URL(
|
||||
env.VITE_PUBLIC_BROWSEROS_API?.trim() || 'https://api.browseros.com',
|
||||
)
|
||||
// biome-ignore lint/style/noNonNullAssertion: required env var
|
||||
const apiUrl = new URL(env.VITE_PUBLIC_BROWSEROS_API!)
|
||||
const apiPattern = apiUrl.port
|
||||
? `${apiUrl.hostname}:${apiUrl.port}`
|
||||
: apiUrl.hostname
|
||||
|
||||
@@ -7,11 +7,6 @@ BROWSEROS_EXTENSION_PORT=9300
|
||||
# BROWSEROS_RESOURCES_DIR=./resources
|
||||
# BROWSEROS_EXECUTION_DIR=./out
|
||||
|
||||
# VM cache (optional - runtime downloads published agent cache in background)
|
||||
# Set prefetch=false to skip startup warmup; VM/OpenClaw startup still syncs on demand.
|
||||
BROWSEROS_VM_CACHE_PREFETCH=true
|
||||
BROWSEROS_VM_CACHE_MANIFEST_URL=https://cdn.browseros.com/vm/manifest.json
|
||||
|
||||
# BrowserOS config
|
||||
BROWSEROS_CONFIG_URL=https://llm.browseros.com/api/browseros-server/config
|
||||
BROWSEROS_VERSION=
|
||||
|
||||
@@ -5,9 +5,6 @@ CODEGEN_SERVICE_URL=
|
||||
POSTHOG_API_KEY=
|
||||
SENTRY_DSN=
|
||||
|
||||
BROWSEROS_VM_CACHE_PREFETCH=true
|
||||
BROWSEROS_VM_CACHE_MANIFEST_URL=https://cdn.browseros.com/vm/manifest.json
|
||||
|
||||
R2_ACCOUNT_ID=
|
||||
R2_ACCESS_KEY_ID=
|
||||
R2_SECRET_ACCESS_KEY=
|
||||
|
||||
@@ -142,7 +142,7 @@ cp .env.example .env.development
|
||||
bun run start
|
||||
```
|
||||
|
||||
See the [agent monorepo README](../../README.md) for full environment variable reference and `dev:watch` setup.
|
||||
See the [agent monorepo README](../../README.md) for full environment variable reference and `process-compose` setup.
|
||||
|
||||
### Testing
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@browseros/server",
|
||||
"version": "0.0.92",
|
||||
"version": "0.0.83",
|
||||
"description": "BrowserOS server",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
@@ -10,21 +10,9 @@
|
||||
"scripts": {
|
||||
"start": "bun --watch --env-file=.env.development src/index.ts",
|
||||
"build": "bun ../../scripts/build/server.ts --target=all",
|
||||
"test": "bun run test:all",
|
||||
"test:all": "bun run ./tests/__helpers__/run-test-group.ts all",
|
||||
"test:agent": "bun run ./tests/__helpers__/run-test-group.ts agent",
|
||||
"test:api": "bun run ./tests/__helpers__/run-test-group.ts api",
|
||||
"test:browser": "bun run ./tests/__helpers__/run-test-group.ts browser",
|
||||
"test:cdp": "bun run test:browser",
|
||||
"test:core": "bun run ./tests/__helpers__/run-test-group.ts core",
|
||||
"test:integration": "bun run ./tests/__helpers__/run-test-group.ts integration",
|
||||
"test:root": "bun run ./tests/__helpers__/run-test-group.ts root",
|
||||
"test:sdk": "bun run ./tests/__helpers__/run-test-group.ts sdk",
|
||||
"test:skills": "bun run ./tests/__helpers__/run-test-group.ts skills",
|
||||
"test:tools": "bun run ./tests/__helpers__/run-test-group.ts tools",
|
||||
"test:tools:acl": "bun run test:cleanup && bun --env-file=.env.development test ./tests/tools/acl-scorer.test.ts",
|
||||
"test:tools:filesystem": "bun run test:cleanup && bun --env-file=.env.development test ./tests/tools/filesystem",
|
||||
"test:tools:input": "bun run test:cleanup && bun --env-file=.env.development test ./tests/tools/input.test.ts",
|
||||
"test:tools": "bun run test:cleanup && bun --env-file=.env.development test tests/tools",
|
||||
"test:integration": "bun run test:cleanup && bun --env-file=.env.development test tests/server.integration.test.ts",
|
||||
"test:sdk": "bun run test:cleanup && bun --env-file=.env.development test tests/sdk",
|
||||
"test:cleanup": "./tests/__helpers__/cleanup.sh",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"devtools": "bunx @ai-sdk/devtools"
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
services:
|
||||
openclaw-gateway:
|
||||
image: ${OPENCLAW_IMAGE:-ghcr.io/openclaw/openclaw:latest}
|
||||
ports:
|
||||
- "127.0.0.1:${OPENCLAW_GATEWAY_PORT:-18789}:18789"
|
||||
environment:
|
||||
- HOME=/home/node
|
||||
- NODE_ENV=production
|
||||
- OPENCLAW_GATEWAY_TOKEN=${OPENCLAW_GATEWAY_TOKEN}
|
||||
- OPENCLAW_GATEWAY_BIND=lan
|
||||
- TZ=${TZ}
|
||||
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||
- GEMINI_API_KEY=${GEMINI_API_KEY:-}
|
||||
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-}
|
||||
- GROQ_API_KEY=${GROQ_API_KEY:-}
|
||||
- MISTRAL_API_KEY=${MISTRAL_API_KEY:-}
|
||||
- MOONSHOT_API_KEY=${MOONSHOT_API_KEY:-}
|
||||
volumes:
|
||||
- ${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw
|
||||
extra_hosts:
|
||||
- "host.containers.internal:host-gateway"
|
||||
command:
|
||||
- node
|
||||
- dist/index.js
|
||||
- gateway
|
||||
- --bind
|
||||
- lan
|
||||
- --port
|
||||
- "18789"
|
||||
- --allow-unconfigured
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-sf", "http://127.0.0.1:18789/healthz"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
restart: unless-stopped
|
||||
@@ -9,10 +9,6 @@ import { LLM_PROVIDERS } from '@browseros/shared/schemas/llm'
|
||||
import { createOpenRouter } from '@openrouter/ai-sdk-provider'
|
||||
import type { LanguageModel } from 'ai'
|
||||
import { createBrowserOSFetch } from '../lib/browseros-fetch'
|
||||
import {
|
||||
createMockBrowserOSLanguageModel,
|
||||
shouldUseMockBrowserOSLLM,
|
||||
} from '../lib/clients/llm/mock-language-model'
|
||||
import { createCodexFetch } from '../lib/clients/oauth/codex-fetch'
|
||||
import { createCopilotFetch } from '../lib/clients/oauth/copilot-fetch'
|
||||
import { logger } from '../lib/logger'
|
||||
@@ -223,9 +219,6 @@ const PROVIDER_FACTORIES: Record<string, ProviderFactory> = {
|
||||
export function createLanguageModel(
|
||||
config: ResolvedAgentConfig,
|
||||
): LanguageModel {
|
||||
if (shouldUseMockBrowserOSLLM(config)) {
|
||||
return createMockBrowserOSLanguageModel()
|
||||
}
|
||||
const provider = config.provider as string
|
||||
const factory = PROVIDER_FACTORIES[provider]
|
||||
if (!factory) throw new Error(`Unknown provider: ${provider}`)
|
||||
|
||||
@@ -10,7 +10,6 @@ import type { Browser } from '../../browser/browser'
|
||||
import { logger } from '../../lib/logger'
|
||||
import { metrics } from '../../lib/metrics'
|
||||
import { Sentry } from '../../lib/sentry'
|
||||
import { getMonitoringService } from '../../monitoring/service'
|
||||
import type { ToolRegistry } from '../../tools/tool-registry'
|
||||
import type { GlobalAclPolicyService } from '../services/acl/global-acl-policy'
|
||||
import { resolveAclPolicyForMcpRequest } from '../services/acl/resolve-acl-policy'
|
||||
@@ -40,30 +39,16 @@ export function createMcpRoutes(deps: McpRouteDeps) {
|
||||
|
||||
app.post('/', async (c) => {
|
||||
const scopeId = c.req.header('X-BrowserOS-Scope-Id') || 'ephemeral'
|
||||
const monitoringService = getMonitoringService()
|
||||
const explicitAgentId =
|
||||
c.req.query('agentId') ??
|
||||
c.req.header('X-BrowserOS-Agent-Id') ??
|
||||
undefined
|
||||
const activeSession =
|
||||
monitoringService.resolveSessionForMcpRequest(explicitAgentId)
|
||||
const agentId = activeSession?.agentId
|
||||
metrics.log('mcp.request', { scopeId })
|
||||
const aclRules = await resolveAclPolicyForMcpRequest({
|
||||
policyService: deps.policyService,
|
||||
})
|
||||
const monitoringSessionId = activeSession?.monitoringSessionId
|
||||
const observer =
|
||||
monitoringSessionId && agentId
|
||||
? monitoringService.createObserver(monitoringSessionId, agentId)
|
||||
: undefined
|
||||
|
||||
// Per-request server + transport: no shared state, no race conditions,
|
||||
// no ID collisions. Required by MCP SDK 1.26.0+ security fix (GHSA-345p-7cg4-v4c7).
|
||||
const mcpServer = createMcpServer({
|
||||
...deps,
|
||||
aclRules,
|
||||
observer,
|
||||
})
|
||||
const transport = new StreamableHTTPTransport({
|
||||
sessionIdGenerator: undefined,
|
||||
@@ -77,9 +62,6 @@ export function createMcpRoutes(deps: McpRouteDeps) {
|
||||
Sentry.withScope((scope) => {
|
||||
scope.setTag('route', 'mcp')
|
||||
scope.setTag('scopeId', scopeId)
|
||||
if (agentId) {
|
||||
scope.setTag('agentId', agentId)
|
||||
}
|
||||
Sentry.captureException(error)
|
||||
})
|
||||
logger.error('Error handling MCP request', {
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import { Hono } from 'hono'
|
||||
import { getMonitoringService } from '../../monitoring/service'
|
||||
import { isValidMonitoringRunId } from '../../monitoring/storage'
|
||||
|
||||
export function createMonitoringRoutes() {
|
||||
return new Hono()
|
||||
.get('/runs', async (c) => {
|
||||
const limitParam = c.req.query('limit')
|
||||
const parsedLimit = limitParam ? Number.parseInt(limitParam, 10) : 50
|
||||
const limit =
|
||||
Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : 50
|
||||
|
||||
const runs = await getMonitoringService().listRuns(limit)
|
||||
return c.json({ runs })
|
||||
})
|
||||
.get('/runs/:id', async (c) => {
|
||||
const runId = c.req.param('id')
|
||||
if (!isValidMonitoringRunId(runId)) {
|
||||
return c.json({ error: 'Invalid monitoring run id' }, 400)
|
||||
}
|
||||
const envelope = await getMonitoringService().getRunEnvelope(runId)
|
||||
|
||||
if (!envelope) {
|
||||
return c.json({ error: 'Monitoring run not found' }, 404)
|
||||
}
|
||||
|
||||
return c.json({ run: envelope })
|
||||
})
|
||||
.post('/debug/runs', async (c) => {
|
||||
const body = await c.req.json<{
|
||||
agentId?: string
|
||||
sessionKey?: string
|
||||
originalPrompt?: string
|
||||
chatHistory?: Array<{ role?: 'user' | 'assistant'; content?: string }>
|
||||
}>()
|
||||
|
||||
if (!body.agentId?.trim()) {
|
||||
return c.json({ error: 'agentId is required' }, 400)
|
||||
}
|
||||
if (!body.sessionKey?.trim()) {
|
||||
return c.json({ error: 'sessionKey is required' }, 400)
|
||||
}
|
||||
if (!body.originalPrompt?.trim()) {
|
||||
return c.json({ error: 'originalPrompt is required' }, 400)
|
||||
}
|
||||
|
||||
const chatHistory = Array.isArray(body.chatHistory)
|
||||
? body.chatHistory
|
||||
.filter(
|
||||
(turn): turn is { role: 'user' | 'assistant'; content: string } =>
|
||||
(turn.role === 'user' || turn.role === 'assistant') &&
|
||||
typeof turn.content === 'string',
|
||||
)
|
||||
.map((turn) => ({
|
||||
role: turn.role,
|
||||
content: turn.content,
|
||||
}))
|
||||
: []
|
||||
|
||||
const session = await getMonitoringService().startSession({
|
||||
agentId: body.agentId.trim(),
|
||||
sessionKey: body.sessionKey.trim(),
|
||||
originalPrompt: body.originalPrompt.trim(),
|
||||
chatHistory,
|
||||
source: 'debug',
|
||||
})
|
||||
|
||||
return c.json({ session }, 201)
|
||||
})
|
||||
.post('/debug/runs/:id/finalize', async (c) => {
|
||||
const runId = c.req.param('id')
|
||||
if (!isValidMonitoringRunId(runId)) {
|
||||
return c.json({ error: 'Invalid monitoring run id' }, 400)
|
||||
}
|
||||
const body = await c.req.json<{
|
||||
agentId?: string
|
||||
sessionKey?: string
|
||||
status?: 'completed' | 'failed' | 'aborted' | 'incomplete'
|
||||
finalAssistantMessage?: string
|
||||
error?: string
|
||||
}>()
|
||||
|
||||
if (!body.agentId?.trim()) {
|
||||
return c.json({ error: 'agentId is required' }, 400)
|
||||
}
|
||||
if (!body.sessionKey?.trim()) {
|
||||
return c.json({ error: 'sessionKey is required' }, 400)
|
||||
}
|
||||
if (
|
||||
body.status !== 'completed' &&
|
||||
body.status !== 'failed' &&
|
||||
body.status !== 'aborted' &&
|
||||
body.status !== 'incomplete'
|
||||
) {
|
||||
return c.json({ error: 'status is invalid' }, 400)
|
||||
}
|
||||
|
||||
const envelope = await getMonitoringService().finalizeSession({
|
||||
monitoringSessionId: runId,
|
||||
agentId: body.agentId.trim(),
|
||||
sessionKey: body.sessionKey.trim(),
|
||||
status: body.status,
|
||||
finalAssistantMessage: body.finalAssistantMessage,
|
||||
error: body.error,
|
||||
})
|
||||
|
||||
if (!envelope) {
|
||||
return c.json({ error: 'Monitoring run not found' }, 404)
|
||||
}
|
||||
|
||||
return c.json({ run: envelope })
|
||||
})
|
||||
}
|
||||
@@ -7,40 +7,38 @@
|
||||
* Thin layer delegating to OpenClawService.
|
||||
*/
|
||||
|
||||
import { OPENCLAW_GATEWAY_PORT } from '@browseros/shared/constants/openclaw'
|
||||
import { BROWSEROS_ROLE_TEMPLATES } from '@browseros/shared/constants/role-aware-agents'
|
||||
import type {
|
||||
BrowserOSAgentRoleId,
|
||||
BrowserOSCustomRoleInput,
|
||||
} from '@browseros/shared/types/role-aware-agents'
|
||||
import { Hono } from 'hono'
|
||||
import { stream } from 'hono/streaming'
|
||||
import { logger } from '../../lib/logger'
|
||||
import { getMonitoringService } from '../../monitoring/service'
|
||||
import type { MonitoringChatTurn } from '../../monitoring/types'
|
||||
import {
|
||||
OpenClawAgentAlreadyExistsError,
|
||||
OpenClawAgentNotFoundError,
|
||||
OpenClawInvalidAgentNameError,
|
||||
OpenClawProtectedAgentError,
|
||||
OpenClawSessionNotFoundError,
|
||||
} from '../services/openclaw/errors'
|
||||
import { getOpenClawCliProvider } from '../services/openclaw/openclaw-cli-providers/registry'
|
||||
import { isUnsupportedOpenClawProviderError } from '../services/openclaw/openclaw-provider-map'
|
||||
import {
|
||||
getOpenClawService,
|
||||
normalizeBrowserOSChatSessionKey,
|
||||
} from '../services/openclaw/openclaw-service'
|
||||
import { getOpenClawService } from '../services/openclaw/openclaw-service'
|
||||
|
||||
function getCreateAgentValidationError(body: { name?: string }): string | null {
|
||||
if (!body.name?.trim()) {
|
||||
return 'Name is required'
|
||||
}
|
||||
return null
|
||||
function isValidBoundaryMode(
|
||||
value: unknown,
|
||||
): value is BrowserOSCustomRoleInput['boundaries'][number]['defaultMode'] {
|
||||
return value === 'allow' || value === 'ask' || value === 'block'
|
||||
}
|
||||
|
||||
function parsePositiveIntQuery(
|
||||
value: string | undefined,
|
||||
fallback: number,
|
||||
): number {
|
||||
if (value === undefined) return fallback
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) return fallback
|
||||
return Math.max(1, Math.trunc(parsed))
|
||||
function isValidCustomRoleBoundary(value: unknown): boolean {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const boundary = value as Record<string, unknown>
|
||||
return (
|
||||
typeof boundary.key === 'string' &&
|
||||
typeof boundary.label === 'string' &&
|
||||
typeof boundary.description === 'string' &&
|
||||
isValidBoundaryMode(boundary.defaultMode)
|
||||
)
|
||||
}
|
||||
|
||||
export function createOpenClawRoutes() {
|
||||
@@ -50,29 +48,6 @@ export function createOpenClawRoutes() {
|
||||
return c.json(status)
|
||||
})
|
||||
|
||||
.get('/providers/:providerId/auth-status', async (c) => {
|
||||
const { providerId } = c.req.param()
|
||||
const provider = getOpenClawCliProvider(providerId)
|
||||
if (!provider) {
|
||||
return c.json({ error: `Unknown CLI provider: ${providerId}` }, 404)
|
||||
}
|
||||
try {
|
||||
const status =
|
||||
await getOpenClawService().getCliProviderAuthStatus(provider)
|
||||
return c.json(status)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
logger.warn('CLI provider auth-status failed', {
|
||||
providerId,
|
||||
error: message,
|
||||
})
|
||||
return c.json(
|
||||
{ installed: false, loggedIn: false, error: message },
|
||||
500,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
.post('/setup', async (c) => {
|
||||
const body = await c.req.json<{
|
||||
providerType?: string
|
||||
@@ -97,7 +72,7 @@ export function createOpenClawRoutes() {
|
||||
return c.json(
|
||||
{
|
||||
status: 'running',
|
||||
port: getOpenClawService().getPort(),
|
||||
port: OPENCLAW_GATEWAY_PORT,
|
||||
agents: agents.map((a) => ({
|
||||
agentId: a.agentId,
|
||||
name: a.name,
|
||||
@@ -114,10 +89,7 @@ export function createOpenClawRoutes() {
|
||||
providerType: body.providerType,
|
||||
providerName: body.providerName,
|
||||
})
|
||||
if (isUnsupportedOpenClawProviderError(err)) {
|
||||
return c.json({ error: err.message }, 400)
|
||||
}
|
||||
if (message.includes('VM runtime is not available')) {
|
||||
if (message.includes('Podman is not available')) {
|
||||
return c.json({ error: message }, 503)
|
||||
}
|
||||
return c.json({ error: message }, 500)
|
||||
@@ -182,23 +154,97 @@ export function createOpenClawRoutes() {
|
||||
}
|
||||
})
|
||||
|
||||
.get('/roles', async (c) => {
|
||||
return c.json({
|
||||
roles: BROWSEROS_ROLE_TEMPLATES.map((role) => ({
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
shortDescription: role.shortDescription,
|
||||
longDescription: role.longDescription,
|
||||
recommendedApps: role.recommendedApps,
|
||||
boundaries: role.boundaries,
|
||||
defaultAgentName: role.defaultAgentName,
|
||||
})),
|
||||
})
|
||||
})
|
||||
|
||||
.post('/agents', async (c) => {
|
||||
const body = await c.req.json<{
|
||||
name: string
|
||||
roleId?: BrowserOSAgentRoleId
|
||||
customRole?: BrowserOSCustomRoleInput
|
||||
providerType?: string
|
||||
providerName?: string
|
||||
baseUrl?: string
|
||||
apiKey?: string
|
||||
modelId?: string
|
||||
}>()
|
||||
const validationError = getCreateAgentValidationError(body)
|
||||
if (validationError) {
|
||||
return c.json({ error: validationError }, 400)
|
||||
const name = body.name?.trim()
|
||||
|
||||
if (!name) {
|
||||
return c.json({ error: 'Name is required' }, 400)
|
||||
}
|
||||
if (body.roleId && body.customRole) {
|
||||
return c.json(
|
||||
{ error: 'Provide either roleId or customRole, not both' },
|
||||
400,
|
||||
)
|
||||
}
|
||||
if (
|
||||
body.customRole &&
|
||||
(!body.customRole.name?.trim() ||
|
||||
!body.customRole.shortDescription?.trim() ||
|
||||
!body.customRole.longDescription?.trim())
|
||||
) {
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
'Custom roles require name, shortDescription, and longDescription',
|
||||
},
|
||||
400,
|
||||
)
|
||||
}
|
||||
if (
|
||||
body.customRole &&
|
||||
(!Array.isArray(body.customRole.recommendedApps) ||
|
||||
!Array.isArray(body.customRole.boundaries))
|
||||
) {
|
||||
return c.json(
|
||||
{
|
||||
error: 'Custom roles require recommendedApps and boundaries arrays',
|
||||
},
|
||||
400,
|
||||
)
|
||||
}
|
||||
if (
|
||||
body.customRole &&
|
||||
!body.customRole.recommendedApps.every((app) => typeof app === 'string')
|
||||
) {
|
||||
return c.json(
|
||||
{
|
||||
error: 'Custom role recommendedApps must be an array of strings',
|
||||
},
|
||||
400,
|
||||
)
|
||||
}
|
||||
if (
|
||||
body.customRole &&
|
||||
!body.customRole.boundaries.every(isValidCustomRoleBoundary)
|
||||
) {
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
'Custom role boundaries must include key, label, description, and a valid defaultMode',
|
||||
},
|
||||
400,
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const agent = await getOpenClawService().createAgent({
|
||||
name: body.name.trim(),
|
||||
name,
|
||||
roleId: body.roleId,
|
||||
customRole: body.customRole,
|
||||
providerType: body.providerType,
|
||||
providerName: body.providerName,
|
||||
baseUrl: body.baseUrl,
|
||||
@@ -213,9 +259,6 @@ export function createOpenClawRoutes() {
|
||||
if (err instanceof OpenClawInvalidAgentNameError) {
|
||||
return c.json({ error: err.message }, 400)
|
||||
}
|
||||
if (isUnsupportedOpenClawProviderError(err)) {
|
||||
return c.json({ error: err.message }, 400)
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
@@ -239,98 +282,24 @@ export function createOpenClawRoutes() {
|
||||
}
|
||||
})
|
||||
|
||||
.get('/agents/:id/sessions', async (c) => {
|
||||
const { id } = c.req.param()
|
||||
const limit = parsePositiveIntQuery(c.req.query('limit'), 20)
|
||||
|
||||
try {
|
||||
const sessions = await getOpenClawService().listSessions(id)
|
||||
return c.json({
|
||||
agentId: id,
|
||||
sessions: sessions.slice(0, Math.min(limit, 100)),
|
||||
})
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
.get('/agents/:id/session', async (c) => {
|
||||
const { id } = c.req.param()
|
||||
|
||||
try {
|
||||
const session = await getOpenClawService().resolveAgentSession(id)
|
||||
return c.json(session)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
.get('/agents/:id/history', async (c) => {
|
||||
const { id } = c.req.param()
|
||||
const limit = parsePositiveIntQuery(c.req.query('limit'), 50)
|
||||
|
||||
try {
|
||||
const page = await getOpenClawService().getAgentHistoryPage(id, {
|
||||
sessionKey: c.req.query('sessionKey'),
|
||||
cursor: c.req.query('cursor'),
|
||||
limit,
|
||||
})
|
||||
return c.json(page)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
.post('/agents/:id/chat', async (c) => {
|
||||
const { id } = c.req.param()
|
||||
const body = await c.req.json<{
|
||||
message: string
|
||||
sessionKey?: string
|
||||
history?: MonitoringChatTurn[]
|
||||
}>()
|
||||
|
||||
if (!body.message?.trim()) {
|
||||
return c.json({ error: 'Message is required' }, 400)
|
||||
}
|
||||
|
||||
const sessionKey = normalizeBrowserOSChatSessionKey(
|
||||
id,
|
||||
body.sessionKey ?? crypto.randomUUID(),
|
||||
)
|
||||
const history = Array.isArray(body.history)
|
||||
? body.history.filter((entry): entry is MonitoringChatTurn =>
|
||||
Boolean(
|
||||
entry &&
|
||||
(entry.role === 'user' || entry.role === 'assistant') &&
|
||||
typeof entry.content === 'string',
|
||||
),
|
||||
)
|
||||
: []
|
||||
if (getMonitoringService().getActiveSessionId(id)) {
|
||||
return c.json(
|
||||
{
|
||||
error:
|
||||
'A monitored chat session is already active for this agent. Wait for it to finish before starting another.',
|
||||
},
|
||||
409,
|
||||
)
|
||||
}
|
||||
const monitoringContext = await getMonitoringService().startSession({
|
||||
agentId: id,
|
||||
sessionKey,
|
||||
originalPrompt: body.message.trim(),
|
||||
chatHistory: history,
|
||||
})
|
||||
const sessionKey = body.sessionKey ?? crypto.randomUUID()
|
||||
|
||||
try {
|
||||
const eventStream = await getOpenClawService().chatStream(
|
||||
id,
|
||||
sessionKey,
|
||||
body.message,
|
||||
history,
|
||||
)
|
||||
|
||||
c.header('Content-Type', 'text/event-stream')
|
||||
@@ -340,123 +309,20 @@ export function createOpenClawRoutes() {
|
||||
return stream(c, async (s) => {
|
||||
const reader = eventStream.getReader()
|
||||
const encoder = new TextEncoder()
|
||||
let finalAssistantMessage: string | undefined
|
||||
let status: 'completed' | 'failed' | 'aborted' | 'incomplete' =
|
||||
'incomplete'
|
||||
let finalError: string | undefined
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
if (
|
||||
value.type === 'done' &&
|
||||
typeof value.data.text === 'string' &&
|
||||
value.data.text.trim()
|
||||
) {
|
||||
finalAssistantMessage = value.data.text
|
||||
status = 'completed'
|
||||
}
|
||||
if (value.type === 'error') {
|
||||
finalError =
|
||||
(typeof value.data.message === 'string'
|
||||
? value.data.message
|
||||
: typeof value.data.error === 'string'
|
||||
? value.data.error
|
||||
: undefined) ?? 'Unknown chat stream error'
|
||||
status = 'failed'
|
||||
}
|
||||
await s.write(
|
||||
encoder.encode(`data: ${JSON.stringify(value)}\n\n`),
|
||||
)
|
||||
}
|
||||
await s.write(encoder.encode('data: [DONE]\n\n'))
|
||||
} catch (error) {
|
||||
if (c.req.raw.signal.aborted) {
|
||||
status = 'aborted'
|
||||
} else {
|
||||
status = 'failed'
|
||||
finalError =
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
await reader.cancel()
|
||||
await getMonitoringService().finalizeSession({
|
||||
monitoringSessionId: monitoringContext.monitoringSessionId,
|
||||
agentId: id,
|
||||
sessionKey,
|
||||
status,
|
||||
finalAssistantMessage,
|
||||
error: finalError,
|
||||
})
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
await getMonitoringService().finalizeSession({
|
||||
monitoringSessionId: monitoringContext.monitoringSessionId,
|
||||
agentId: id,
|
||||
sessionKey,
|
||||
status: c.req.raw.signal.aborted ? 'aborted' : 'failed',
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
if (isUnsupportedOpenClawProviderError(err)) {
|
||||
return c.json({ error: err.message }, 400)
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
.get('/session/:key/history', async (c) => {
|
||||
const key = c.req.param('key')
|
||||
const limitRaw = c.req.query('limit')
|
||||
const cursor = c.req.query('cursor')
|
||||
const limitParsed =
|
||||
limitRaw !== undefined ? Number.parseInt(limitRaw, 10) : Number.NaN
|
||||
const limit = Number.isFinite(limitParsed) ? limitParsed : undefined
|
||||
const wantsStream = (c.req.header('accept') ?? '').includes(
|
||||
'text/event-stream',
|
||||
)
|
||||
|
||||
try {
|
||||
if (!wantsStream) {
|
||||
const history = await getOpenClawService().getSessionHistory(key, {
|
||||
limit,
|
||||
cursor,
|
||||
})
|
||||
return c.json(history)
|
||||
}
|
||||
|
||||
const eventStream = await getOpenClawService().streamSessionHistory(
|
||||
key,
|
||||
{ limit, cursor, signal: c.req.raw.signal },
|
||||
)
|
||||
|
||||
c.header('Content-Type', 'text/event-stream')
|
||||
c.header('Cache-Control', 'no-cache')
|
||||
c.header('X-Session-Key', key)
|
||||
|
||||
return stream(c, async (s) => {
|
||||
const reader = eventStream.getReader()
|
||||
const encoder = new TextEncoder()
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
await s.write(
|
||||
encoder.encode(
|
||||
`event: ${value.type}\ndata: ${JSON.stringify(value.data)}\n\n`,
|
||||
),
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
await reader.cancel()
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof OpenClawSessionNotFoundError) {
|
||||
return c.json({ error: err.message }, 404)
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
@@ -486,17 +352,12 @@ export function createOpenClawRoutes() {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await getOpenClawService().updateProviderKeys(body)
|
||||
await getOpenClawService().updateProviderKeys(body)
|
||||
return c.json({
|
||||
status: result.restarted ? 'restarting' : 'updated',
|
||||
message: result.restarted
|
||||
? 'Provider updated, restarting gateway'
|
||||
: 'Provider updated without a restart',
|
||||
status: 'restarting',
|
||||
message: 'Provider updated, restarting gateway',
|
||||
})
|
||||
} catch (err) {
|
||||
if (isUnsupportedOpenClawProviderError(err)) {
|
||||
return c.json({ error: err.message }, 400)
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return c.json({ error: message }, 500)
|
||||
}
|
||||
|
||||
@@ -16,9 +16,7 @@ export const TERMINAL_WS_PATH = '/terminal/ws'
|
||||
|
||||
interface TerminalRouteDeps {
|
||||
containerName: string
|
||||
limaHome: string
|
||||
limactlPath: string
|
||||
vmName: string
|
||||
podmanPath: string
|
||||
}
|
||||
|
||||
function safeSend(ws: { send(data: string): void }, data: string): void {
|
||||
@@ -47,9 +45,7 @@ function createSocketEvents(deps: TerminalRouteDeps) {
|
||||
try {
|
||||
session = createTerminalSession({
|
||||
containerName: deps.containerName,
|
||||
limaHome: deps.limaHome,
|
||||
limactlPath: deps.limactlPath,
|
||||
vmName: deps.vmName,
|
||||
podmanPath: deps.podmanPath,
|
||||
workingDir: TERMINAL_HOME_DIR,
|
||||
onOutput(data) {
|
||||
sendOutput(ws, data)
|
||||
|
||||
@@ -22,7 +22,6 @@ import { initializeOAuth } from '../lib/clients/oauth'
|
||||
import { getDb } from '../lib/db'
|
||||
import { logger } from '../lib/logger'
|
||||
import { Sentry } from '../lib/sentry'
|
||||
import { getLimaHomeDir, resolveBundledLimactl, VM_NAME } from '../lib/vm'
|
||||
import { createAclRoutes } from './routes/acl'
|
||||
import { createChatRoutes } from './routes/chat'
|
||||
import { createCreditsRoutes } from './routes/credits'
|
||||
@@ -30,7 +29,6 @@ import { createHealthRoute } from './routes/health'
|
||||
import { createKlavisRoutes } from './routes/klavis'
|
||||
import { createMcpRoutes } from './routes/mcp'
|
||||
import { createMemoryRoutes } from './routes/memory'
|
||||
import { createMonitoringRoutes } from './routes/monitoring'
|
||||
import { createOAuthRoutes } from './routes/oauth'
|
||||
import { createOpenClawRoutes } from './routes/openclaw'
|
||||
import { createProviderRoutes } from './routes/provider'
|
||||
@@ -46,6 +44,7 @@ import {
|
||||
connectKlavisInBackground,
|
||||
type KlavisProxyRef,
|
||||
} from './services/klavis/strata-proxy'
|
||||
import { getPodmanRuntime } from './services/openclaw/podman-runtime'
|
||||
import type { Env, HttpServerConfig } from './types'
|
||||
import { defaultCorsConfig } from './utils/cors'
|
||||
import { requireTrustedAppOrigin } from './utils/request-auth'
|
||||
@@ -114,9 +113,7 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
'/',
|
||||
createTerminalRoutes({
|
||||
containerName: OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
limaHome: getLimaHomeDir(),
|
||||
limactlPath: resolveBundledLimactl(resourcesDir),
|
||||
vmName: VM_NAME,
|
||||
podmanPath: getPodmanRuntime().getPodmanPath(),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -124,10 +121,6 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
.use('/*', requireTrustedAppOrigin())
|
||||
.route('/', createAclRoutes({ policyService: aclPolicyService }))
|
||||
|
||||
const monitoringRoutes = new Hono<Env>()
|
||||
.use('/*', requireTrustedAppOrigin())
|
||||
.route('/', createMonitoringRoutes())
|
||||
|
||||
const app = new Hono<Env>()
|
||||
.use('/*', cors(defaultCorsConfig))
|
||||
.route('/health', createHealthRoute({ browser }))
|
||||
@@ -150,7 +143,6 @@ export async function createHttpServer(config: HttpServerConfig) {
|
||||
.route('/soul', createSoulRoutes())
|
||||
.route('/memory', createMemoryRoutes())
|
||||
.route('/skills', createSkillsRoutes())
|
||||
.route('/monitoring', monitoringRoutes)
|
||||
.route('/acl-rules', aclRoutes)
|
||||
.route('/test-provider', createProviderRoutes({ browserosId }))
|
||||
.route('/refine-prompt', createRefinePromptRoutes({ browserosId }))
|
||||
|
||||
@@ -20,10 +20,6 @@ import { KlavisClient } from '../../../lib/clients/klavis/klavis-client'
|
||||
import { OAUTH_MCP_SERVERS } from '../../../lib/clients/klavis/oauth-mcp-servers'
|
||||
import { logger } from '../../../lib/logger'
|
||||
import { metrics } from '../../../lib/metrics'
|
||||
import {
|
||||
buildMonitoringToolOutput,
|
||||
type ToolExecutionObserver,
|
||||
} from '../../../monitoring/observer'
|
||||
import { klavisStrataCache } from './strata-cache'
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, label: string): Promise<T> {
|
||||
@@ -241,7 +237,6 @@ export function buildKlavisToolSet(handle: KlavisProxyHandle): ToolSet {
|
||||
export function registerKlavisTools(
|
||||
mcpServer: McpServer,
|
||||
handle: KlavisProxyHandle,
|
||||
observer?: ToolExecutionObserver,
|
||||
): void {
|
||||
mcpServer.registerTool(
|
||||
'connector_mcp_servers',
|
||||
@@ -252,18 +247,9 @@ export function registerKlavisTools(
|
||||
},
|
||||
async (args: Record<string, unknown>) => {
|
||||
const startTime = performance.now()
|
||||
const toolCallId = crypto.randomUUID()
|
||||
const server_name = args.server_name as string
|
||||
|
||||
try {
|
||||
await observer?.onToolStart({
|
||||
toolCallId,
|
||||
toolName: 'connector_mcp_servers',
|
||||
toolDescription:
|
||||
'Check whether an external connector is connected and ready for use.',
|
||||
source: 'klavis-tool',
|
||||
args,
|
||||
})
|
||||
const klavisClient = new KlavisClient()
|
||||
const integrations = await klavisClient.getUserIntegrations(
|
||||
handle.browserosId,
|
||||
@@ -280,14 +266,6 @@ export function registerKlavisTools(
|
||||
success: true,
|
||||
})
|
||||
|
||||
await observer?.onToolEnd({
|
||||
toolCallId,
|
||||
output: {
|
||||
connected: true,
|
||||
server_name,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
@@ -316,15 +294,6 @@ export function registerKlavisTools(
|
||||
success: true,
|
||||
})
|
||||
|
||||
await observer?.onToolEnd({
|
||||
toolCallId,
|
||||
output: {
|
||||
connected: false,
|
||||
server_name,
|
||||
authUrl,
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
@@ -351,11 +320,6 @@ export function registerKlavisTools(
|
||||
error_message: errorText,
|
||||
})
|
||||
|
||||
await observer?.onToolEnd({
|
||||
toolCallId,
|
||||
error: errorText,
|
||||
})
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: errorText }],
|
||||
isError: true,
|
||||
@@ -375,15 +339,7 @@ export function registerKlavisTools(
|
||||
},
|
||||
async (args: Record<string, unknown>) => {
|
||||
const startTime = performance.now()
|
||||
const toolCallId = crypto.randomUUID()
|
||||
try {
|
||||
await observer?.onToolStart({
|
||||
toolCallId,
|
||||
toolName: tool.name,
|
||||
toolDescription: tool.description ?? undefined,
|
||||
source: 'klavis-tool',
|
||||
args,
|
||||
})
|
||||
const result = await handle.callTool(tool.name, args)
|
||||
|
||||
metrics.log('tool_executed', {
|
||||
@@ -393,12 +349,6 @@ export function registerKlavisTools(
|
||||
success: !result.isError,
|
||||
})
|
||||
|
||||
await observer?.onToolEnd({
|
||||
toolCallId,
|
||||
output: buildMonitoringToolOutput(result),
|
||||
error: result.isError ? 'Tool returned isError=true' : undefined,
|
||||
})
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
const errorText =
|
||||
@@ -412,11 +362,6 @@ export function registerKlavisTools(
|
||||
error_message: errorText,
|
||||
})
|
||||
|
||||
await observer?.onToolEnd({
|
||||
toolCallId,
|
||||
error: errorText,
|
||||
})
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: errorText }],
|
||||
isError: true,
|
||||
|
||||
@@ -8,7 +8,6 @@ import type { AclRule } from '@browseros/shared/types/acl'
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { SetLevelRequestSchema } from '@modelcontextprotocol/sdk/types.js'
|
||||
import type { Browser } from '../../../browser/browser'
|
||||
import type { ToolExecutionObserver } from '../../../monitoring/observer'
|
||||
import type { ToolRegistry } from '../../../tools/tool-registry'
|
||||
import {
|
||||
type KlavisProxyRef,
|
||||
@@ -25,7 +24,6 @@ export interface McpServiceDeps {
|
||||
resourcesDir: string
|
||||
aclRules?: AclRule[]
|
||||
klavisRef?: KlavisProxyRef
|
||||
observer?: ToolExecutionObserver
|
||||
}
|
||||
|
||||
export function createMcpServer(deps: McpServiceDeps): McpServer {
|
||||
@@ -50,12 +48,11 @@ export function createMcpServer(deps: McpServiceDeps): McpServer {
|
||||
resourcesDir: deps.resourcesDir,
|
||||
},
|
||||
aclRules: deps.aclRules,
|
||||
observer: deps.observer,
|
||||
})
|
||||
|
||||
// Register Klavis proxy tools (if connected via background init)
|
||||
if (deps.klavisRef?.handle) {
|
||||
registerKlavisTools(server, deps.klavisRef.handle, deps.observer)
|
||||
registerKlavisTools(server, deps.klavisRef.handle)
|
||||
}
|
||||
|
||||
return server
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { logger } from '../../../lib/logger'
|
||||
import { metrics } from '../../../lib/metrics'
|
||||
import {
|
||||
buildMonitoringToolOutput,
|
||||
type ToolExecutionObserver,
|
||||
} from '../../../monitoring/observer'
|
||||
import { executeTool, type ToolContext } from '../../../tools/framework'
|
||||
import type { ToolRegistry } from '../../../tools/tool-registry'
|
||||
|
||||
export function registerTools(
|
||||
mcpServer: McpServer,
|
||||
registry: ToolRegistry,
|
||||
ctx: ToolContext & { observer?: ToolExecutionObserver },
|
||||
ctx: ToolContext,
|
||||
): void {
|
||||
for (const tool of registry.all()) {
|
||||
const handler = async (
|
||||
@@ -19,17 +15,9 @@ export function registerTools(
|
||||
extra: { signal: AbortSignal },
|
||||
) => {
|
||||
const startTime = performance.now()
|
||||
const toolCallId = crypto.randomUUID()
|
||||
|
||||
try {
|
||||
logger.info(`${tool.name} request: ${JSON.stringify(args, null, ' ')}`)
|
||||
await ctx.observer?.onToolStart({
|
||||
toolCallId,
|
||||
toolName: tool.name,
|
||||
toolDescription: tool.description,
|
||||
source: 'browser-tool',
|
||||
args,
|
||||
})
|
||||
|
||||
const result = await executeTool(tool, args, ctx, extra.signal)
|
||||
|
||||
@@ -40,17 +28,6 @@ export function registerTools(
|
||||
source: 'mcp',
|
||||
})
|
||||
|
||||
await ctx.observer?.onToolEnd({
|
||||
toolCallId,
|
||||
output: buildMonitoringToolOutput({
|
||||
content: result.content,
|
||||
structuredContent: result.structuredContent,
|
||||
metadata: result.metadata,
|
||||
isError: result.isError,
|
||||
}),
|
||||
error: result.isError ? 'Tool returned isError=true' : undefined,
|
||||
})
|
||||
|
||||
return {
|
||||
content: result.content,
|
||||
isError: result.isError,
|
||||
@@ -67,11 +44,6 @@ export function registerTools(
|
||||
source: 'mcp',
|
||||
})
|
||||
|
||||
await ctx.observer?.onToolEnd({
|
||||
toolCallId,
|
||||
error: errorText,
|
||||
})
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: errorText }],
|
||||
isError: true,
|
||||
|
||||
@@ -1,229 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { cpSync, existsSync, mkdirSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { getBrowserosDir } from '../../../lib/browseros-dir'
|
||||
import { ContainerCli, ImageLoader } from '../../../lib/container'
|
||||
import { logger } from '../../../lib/logger'
|
||||
import {
|
||||
detectArch,
|
||||
getLimaHomeDir,
|
||||
resolveBundledLimactl,
|
||||
resolveBundledLimaTemplate,
|
||||
VM_NAME,
|
||||
VmRuntime,
|
||||
} from '../../../lib/vm'
|
||||
import {
|
||||
ensureVmCacheAvailable,
|
||||
ensureVmCacheSynced,
|
||||
type VmCacheSyncOptions,
|
||||
} from '../../../lib/vm/cache-sync'
|
||||
import { readCachedManifest } from '../../../lib/vm/manifest'
|
||||
import { VM_TELEMETRY_EVENTS } from '../../../lib/vm/telemetry'
|
||||
import { ContainerRuntime } from './container-runtime'
|
||||
|
||||
const UNSUPPORTED_PLATFORM_MESSAGE =
|
||||
'browseros-vm currently supports macOS only; see the Linux/Windows tracking issue'
|
||||
|
||||
export interface ContainerRuntimeFactoryInput {
|
||||
resourcesDir?: string
|
||||
projectDir: string
|
||||
browserosRoot?: string
|
||||
platform?: NodeJS.Platform
|
||||
vmCache?: VmCacheRuntimeConfig
|
||||
}
|
||||
|
||||
export interface VmCacheRuntimeConfig
|
||||
extends Pick<VmCacheSyncOptions, 'manifestUrl'> {
|
||||
ensureAvailable?: () => Promise<void>
|
||||
ensureSynced?: () => Promise<unknown>
|
||||
}
|
||||
|
||||
export function buildContainerRuntime(
|
||||
input: ContainerRuntimeFactoryInput,
|
||||
): ContainerRuntime {
|
||||
const platform = input.platform ?? process.platform
|
||||
if (platform !== 'darwin') {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
return new UnsupportedPlatformTestRuntime(input.projectDir)
|
||||
}
|
||||
throw unsupportedPlatformError()
|
||||
}
|
||||
|
||||
const browserosRoot = input.browserosRoot ?? getBrowserosDir()
|
||||
if (input.resourcesDir) {
|
||||
migrateLegacyOpenClawDirSync(browserosRoot)
|
||||
}
|
||||
|
||||
const limactlPath = input.resourcesDir
|
||||
? resolveBundledLimactl(input.resourcesDir)
|
||||
: 'limactl'
|
||||
const limaHome = getLimaHomeDir(browserosRoot)
|
||||
const vm = new VmRuntime({
|
||||
limactlPath,
|
||||
limaHome,
|
||||
templatePath: input.resourcesDir
|
||||
? resolveBundledLimaTemplate(input.resourcesDir)
|
||||
: undefined,
|
||||
browserosRoot,
|
||||
ensureCacheAvailable:
|
||||
input.vmCache?.ensureAvailable ??
|
||||
(() =>
|
||||
ensureVmCacheAvailable({
|
||||
browserosRoot,
|
||||
manifestUrl: input.vmCache?.manifestUrl,
|
||||
})),
|
||||
})
|
||||
const shell = new ContainerCli({ limactlPath, limaHome, vmName: VM_NAME })
|
||||
const loader = new DeferredImageLoader(shell, browserosRoot, input.vmCache)
|
||||
|
||||
return new ContainerRuntime({
|
||||
vm,
|
||||
shell,
|
||||
loader,
|
||||
projectDir: input.projectDir,
|
||||
})
|
||||
}
|
||||
|
||||
export async function migrateLegacyOpenClawDir(
|
||||
browserosRoot = getBrowserosDir(),
|
||||
): Promise<void> {
|
||||
migrateLegacyOpenClawDirSync(browserosRoot)
|
||||
}
|
||||
|
||||
function migrateLegacyOpenClawDirSync(browserosRoot = getBrowserosDir()): void {
|
||||
const legacyDir = join(browserosRoot, 'openclaw')
|
||||
const nextDir = join(browserosRoot, 'vm', 'openclaw')
|
||||
if (!existsSync(legacyDir)) return
|
||||
if (existsSync(nextDir)) {
|
||||
logger.warn('OpenClaw legacy and VM state directories both exist', {
|
||||
legacyDir,
|
||||
nextDir,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
mkdirSync(dirname(nextDir), { recursive: true })
|
||||
cpSync(legacyDir, nextDir, { recursive: true })
|
||||
logger.info(VM_TELEMETRY_EVENTS.migrationOpenClawMoved, {
|
||||
from: legacyDir,
|
||||
to: nextDir,
|
||||
})
|
||||
}
|
||||
|
||||
class DeferredImageLoader {
|
||||
constructor(
|
||||
private readonly shell: ContainerCli,
|
||||
private readonly browserosRoot: string,
|
||||
private readonly vmCache?: VmCacheRuntimeConfig,
|
||||
) {}
|
||||
|
||||
async ensureImageLoaded(ref: string, onLog?: (msg: string) => void) {
|
||||
await this.ensureCacheSynced()
|
||||
const manifest = await readCachedManifest(this.browserosRoot)
|
||||
const loader = new ImageLoader(
|
||||
this.shell,
|
||||
manifest,
|
||||
detectArch(),
|
||||
this.browserosRoot,
|
||||
)
|
||||
await loader.ensureImageLoaded(ref, onLog)
|
||||
}
|
||||
|
||||
private async ensureCacheSynced(): Promise<void> {
|
||||
if (this.vmCache?.ensureSynced) {
|
||||
await this.vmCache.ensureSynced()
|
||||
return
|
||||
}
|
||||
await ensureVmCacheSynced({
|
||||
browserosRoot: this.browserosRoot,
|
||||
manifestUrl: this.vmCache?.manifestUrl,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
class UnsupportedPlatformTestRuntime extends ContainerRuntime {
|
||||
constructor(projectDir: string) {
|
||||
super({
|
||||
vm: {} as VmRuntime,
|
||||
shell: {} as ContainerCli,
|
||||
loader: { ensureImageLoaded: rejectUnsupportedPlatform },
|
||||
projectDir,
|
||||
})
|
||||
}
|
||||
|
||||
override async ensureReady(): Promise<void> {
|
||||
throw unsupportedPlatformError()
|
||||
}
|
||||
|
||||
override async isPodmanAvailable(): Promise<boolean> {
|
||||
return false
|
||||
}
|
||||
|
||||
override async getMachineStatus(): Promise<{
|
||||
initialized: boolean
|
||||
running: boolean
|
||||
}> {
|
||||
return { initialized: false, running: false }
|
||||
}
|
||||
|
||||
override async pullImage(): Promise<void> {
|
||||
throw unsupportedPlatformError()
|
||||
}
|
||||
|
||||
override async startGateway(): Promise<void> {
|
||||
throw unsupportedPlatformError()
|
||||
}
|
||||
|
||||
override async stopGateway(): Promise<void> {}
|
||||
|
||||
override async restartGateway(): Promise<void> {
|
||||
throw unsupportedPlatformError()
|
||||
}
|
||||
|
||||
override async getGatewayLogs(): Promise<string[]> {
|
||||
return []
|
||||
}
|
||||
|
||||
override async isHealthy(): Promise<boolean> {
|
||||
return false
|
||||
}
|
||||
|
||||
override async isReady(): Promise<boolean> {
|
||||
return false
|
||||
}
|
||||
|
||||
override async waitForReady(): Promise<boolean> {
|
||||
return false
|
||||
}
|
||||
|
||||
override async stopVm(): Promise<void> {}
|
||||
|
||||
override async execInContainer(): Promise<number> {
|
||||
throw unsupportedPlatformError()
|
||||
}
|
||||
|
||||
override async runInContainer(): Promise<never> {
|
||||
throw unsupportedPlatformError()
|
||||
}
|
||||
|
||||
override async runGatewaySetupCommand(): Promise<number> {
|
||||
throw unsupportedPlatformError()
|
||||
}
|
||||
|
||||
override tailGatewayLogs(): () => void {
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectUnsupportedPlatform(): Promise<never> {
|
||||
throw unsupportedPlatformError()
|
||||
}
|
||||
|
||||
function unsupportedPlatformError(): Error {
|
||||
return new Error(UNSUPPORTED_PLATFORM_MESSAGE)
|
||||
}
|
||||
@@ -2,316 +2,191 @@
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Compose-level abstraction over PodmanRuntime.
|
||||
* Manages a single compose project for the OpenClaw gateway container.
|
||||
*/
|
||||
|
||||
import { copyFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
OPENCLAW_COMPOSE_PROJECT_NAME,
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
OPENCLAW_GATEWAY_CONTAINER_PORT,
|
||||
} from '@browseros/shared/constants/openclaw'
|
||||
import type {
|
||||
ContainerCli,
|
||||
ContainerCommandResult,
|
||||
ContainerSpec,
|
||||
LogFn,
|
||||
} from '../../../lib/container'
|
||||
import { logger } from '../../../lib/logger'
|
||||
import {
|
||||
GUEST_VM_STATE,
|
||||
hostPathToGuest,
|
||||
type VmRuntime,
|
||||
} from '../../../lib/vm'
|
||||
import type { LogFn, PodmanRuntime } from './podman-runtime'
|
||||
|
||||
const GATEWAY_CONTAINER_HOME = '/home/node'
|
||||
const GATEWAY_STATE_DIR = `${GATEWAY_CONTAINER_HOME}/.openclaw`
|
||||
const GUEST_OPENCLAW_HOME = `${GUEST_VM_STATE}/openclaw`
|
||||
const GATEWAY_NPM_PREFIX = `${GATEWAY_CONTAINER_HOME}/.npm-global`
|
||||
// Prepend user-installed bin so tools like `claude` / `gemini` CLI that
|
||||
// are installed via npm into the mounted home are discoverable by
|
||||
// OpenClaw's child-process spawns (no login shell is involved).
|
||||
const GATEWAY_PATH = [
|
||||
`${GATEWAY_NPM_PREFIX}/bin`,
|
||||
'/usr/local/sbin',
|
||||
'/usr/local/bin',
|
||||
'/usr/sbin',
|
||||
'/usr/bin',
|
||||
'/sbin',
|
||||
'/bin',
|
||||
].join(':')
|
||||
|
||||
export type GatewayContainerSpec = {
|
||||
image: string
|
||||
hostPort: number
|
||||
hostHome: string
|
||||
envFilePath: string
|
||||
gatewayToken?: string
|
||||
timezone: string
|
||||
}
|
||||
|
||||
export interface ContainerRuntimeConfig {
|
||||
vm: VmRuntime
|
||||
shell: ContainerCli
|
||||
loader: { ensureImageLoaded(ref: string, onLog?: LogFn): Promise<void> }
|
||||
projectDir: string
|
||||
}
|
||||
const COMPOSE_FILE_NAME = 'docker-compose.yml'
|
||||
const ENV_FILE_NAME = '.env'
|
||||
|
||||
export class ContainerRuntime {
|
||||
private readonly vm: VmRuntime
|
||||
private readonly shell: ContainerCli
|
||||
private readonly loader: {
|
||||
ensureImageLoaded(ref: string, onLog?: LogFn): Promise<void>
|
||||
}
|
||||
private readonly projectDir: string
|
||||
|
||||
constructor(config: ContainerRuntimeConfig) {
|
||||
this.vm = config.vm
|
||||
this.shell = config.shell
|
||||
this.loader = config.loader
|
||||
this.projectDir = config.projectDir
|
||||
}
|
||||
constructor(
|
||||
private podman: PodmanRuntime,
|
||||
private projectDir: string,
|
||||
) {}
|
||||
|
||||
async ensureReady(onLog?: LogFn): Promise<void> {
|
||||
logger.info('Ensuring BrowserOS VM runtime readiness')
|
||||
await this.vm.ensureReady(onLog)
|
||||
await this.vm.getDefaultGateway()
|
||||
logger.info('Ensuring Podman runtime readiness')
|
||||
return this.podman.ensureReady(onLog)
|
||||
}
|
||||
|
||||
async isPodmanAvailable(): Promise<boolean> {
|
||||
return true
|
||||
return this.podman.isPodmanAvailable()
|
||||
}
|
||||
|
||||
async getMachineStatus(): Promise<{
|
||||
initialized: boolean
|
||||
running: boolean
|
||||
}> {
|
||||
const running = await this.vm.isReady()
|
||||
return { initialized: running, running }
|
||||
return this.podman.getMachineStatus()
|
||||
}
|
||||
|
||||
async pullImage(image: string, onLog?: LogFn): Promise<void> {
|
||||
await this.loader.ensureImageLoaded(image, onLog)
|
||||
async composeUp(onLog?: LogFn): Promise<void> {
|
||||
const code = await this.compose(['up', '-d'], onLog)
|
||||
if (code !== 0) throw new Error(`compose up failed with code ${code}`)
|
||||
}
|
||||
|
||||
async startGateway(
|
||||
input: GatewayContainerSpec,
|
||||
onLog?: LogFn,
|
||||
): Promise<void> {
|
||||
await this.removeGatewayContainer(onLog)
|
||||
await this.loader.ensureImageLoaded(input.image, onLog)
|
||||
const container = await this.buildGatewayContainerSpec(input)
|
||||
await this.shell.createContainer(container, onLog)
|
||||
await this.shell.startContainer(container.name)
|
||||
async composeDown(onLog?: LogFn): Promise<void> {
|
||||
const code = await this.compose(['down'], onLog)
|
||||
if (code !== 0) throw new Error(`compose down failed with code ${code}`)
|
||||
}
|
||||
|
||||
async stopGateway(onLog?: LogFn): Promise<void> {
|
||||
await this.removeGatewayContainer(onLog)
|
||||
async composeStop(onLog?: LogFn): Promise<void> {
|
||||
const code = await this.compose(['stop'], onLog)
|
||||
if (code !== 0) throw new Error(`compose stop failed with code ${code}`)
|
||||
}
|
||||
|
||||
async restartGateway(
|
||||
input: GatewayContainerSpec,
|
||||
onLog?: LogFn,
|
||||
): Promise<void> {
|
||||
await this.startGateway(input, onLog)
|
||||
async composeRestart(onLog?: LogFn): Promise<void> {
|
||||
const code = await this.compose(['restart'], onLog)
|
||||
if (code !== 0) throw new Error(`compose restart failed with code ${code}`)
|
||||
}
|
||||
|
||||
async getGatewayLogs(tail = 50): Promise<string[]> {
|
||||
async composePull(onLog?: LogFn): Promise<void> {
|
||||
const code = await this.compose(['pull', '--quiet'], onLog)
|
||||
if (code !== 0) throw new Error(`compose pull failed with code ${code}`)
|
||||
}
|
||||
|
||||
async composeLogs(tail = 50): Promise<string[]> {
|
||||
const lines: string[] = []
|
||||
await this.shell.runCommand(
|
||||
['logs', '-n', String(tail), OPENCLAW_GATEWAY_CONTAINER_NAME],
|
||||
(line) => lines.push(line),
|
||||
await this.compose(['logs', '--no-color', '--tail', String(tail)], (line) =>
|
||||
lines.push(line),
|
||||
)
|
||||
return lines
|
||||
}
|
||||
|
||||
async isHealthy(hostPort: number): Promise<boolean> {
|
||||
async isHealthy(port: number): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${hostPort}/healthz`)
|
||||
const res = await fetch(`http://127.0.0.1:${port}/healthz`)
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async isReady(hostPort: number): Promise<boolean> {
|
||||
async isReady(port: number): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${hostPort}/readyz`)
|
||||
const res = await fetch(`http://127.0.0.1:${port}/readyz`)
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async waitForReady(hostPort: number, timeoutMs = 30_000): Promise<boolean> {
|
||||
logger.info('Waiting for OpenClaw gateway readiness', {
|
||||
hostPort,
|
||||
timeoutMs,
|
||||
})
|
||||
async waitForReady(port: number, timeoutMs = 30_000): Promise<boolean> {
|
||||
logger.info('Waiting for OpenClaw gateway readiness', { port, timeoutMs })
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (await this.isReady(hostPort)) return true
|
||||
if (await this.isReady(port)) {
|
||||
logger.info('OpenClaw gateway became ready', {
|
||||
port,
|
||||
waitMs: Date.now() - start,
|
||||
})
|
||||
return true
|
||||
}
|
||||
await Bun.sleep(1000)
|
||||
}
|
||||
logger.error('Timed out waiting for OpenClaw gateway readiness', {
|
||||
hostPort,
|
||||
port,
|
||||
timeoutMs,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
async stopVm(): Promise<void> {
|
||||
await this.vm.stopVm()
|
||||
async copyComposeFile(sourceTemplatePath: string): Promise<void> {
|
||||
await copyFile(sourceTemplatePath, join(this.projectDir, COMPOSE_FILE_NAME))
|
||||
}
|
||||
|
||||
async writeEnvFile(content: string): Promise<void> {
|
||||
await writeFile(join(this.projectDir, ENV_FILE_NAME), content, {
|
||||
mode: 0o600,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the Podman machine only if no non-BrowserOS containers are running.
|
||||
* Prevents killing the user's own Podman workloads.
|
||||
*/
|
||||
async stopMachineIfSafe(): Promise<void> {
|
||||
const status = await this.podman.getMachineStatus()
|
||||
if (!status.running) return
|
||||
|
||||
try {
|
||||
const containers = await this.podman.listRunningContainers()
|
||||
const allOurs = containers.every((name) =>
|
||||
name.startsWith(OPENCLAW_COMPOSE_PROJECT_NAME),
|
||||
)
|
||||
|
||||
if (containers.length === 0 || allOurs) {
|
||||
await this.podman.stopMachine()
|
||||
}
|
||||
} catch {
|
||||
// Best effort — don't stop machine if we can't check
|
||||
}
|
||||
}
|
||||
|
||||
async execInContainer(command: string[], onLog?: LogFn): Promise<number> {
|
||||
return this.shell.exec(OPENCLAW_GATEWAY_CONTAINER_NAME, command, onLog)
|
||||
}
|
||||
|
||||
// Unlike execInContainer, this returns stdout and stderr separately
|
||||
// so callers that need to parse program output (e.g. JSON status
|
||||
// commands) aren't forced to untangle it from nerdctl's stderr.
|
||||
async runInContainer(command: string[]): Promise<ContainerCommandResult> {
|
||||
return this.shell.runCommand([
|
||||
'exec',
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
...command,
|
||||
])
|
||||
}
|
||||
|
||||
async runGatewaySetupCommand(
|
||||
command: string[],
|
||||
spec: GatewayContainerSpec,
|
||||
onLog?: LogFn,
|
||||
): Promise<number> {
|
||||
const setupContainerName = `${OPENCLAW_GATEWAY_CONTAINER_NAME}-setup`
|
||||
await this.shell.removeContainer(setupContainerName, { force: true }, onLog)
|
||||
await this.loader.ensureImageLoaded(spec.image, onLog)
|
||||
const setupArgs = command[0] === 'node' ? command.slice(1) : command
|
||||
const createResult = await this.shell.runCommand(
|
||||
[
|
||||
'create',
|
||||
'--name',
|
||||
setupContainerName,
|
||||
...(await this.buildGatewayRunArgs(spec)),
|
||||
spec.image,
|
||||
'node',
|
||||
...setupArgs,
|
||||
],
|
||||
onLog,
|
||||
return this.podman.runCommand(
|
||||
['exec', OPENCLAW_GATEWAY_CONTAINER_NAME, ...command],
|
||||
{
|
||||
onOutput: onLog,
|
||||
},
|
||||
)
|
||||
if (createResult.exitCode !== 0) {
|
||||
await this.shell.removeContainer(
|
||||
setupContainerName,
|
||||
{ force: true },
|
||||
onLog,
|
||||
)
|
||||
return createResult.exitCode
|
||||
}
|
||||
|
||||
try {
|
||||
const startResult = await this.shell.runCommand(
|
||||
['start', '-a', setupContainerName],
|
||||
onLog,
|
||||
)
|
||||
return startResult.exitCode
|
||||
} finally {
|
||||
await this.shell.removeContainer(
|
||||
setupContainerName,
|
||||
{ force: true },
|
||||
onLog,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
tailGatewayLogs(onLine: LogFn): () => void {
|
||||
return this.shell.tailLogs(OPENCLAW_GATEWAY_CONTAINER_NAME, onLine)
|
||||
}
|
||||
|
||||
private async removeGatewayContainer(onLog?: LogFn): Promise<void> {
|
||||
await this.shell.removeContainer(
|
||||
return this.podman.tailContainerLogs(
|
||||
OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
{ force: true },
|
||||
onLog,
|
||||
onLine,
|
||||
)
|
||||
}
|
||||
|
||||
private async buildGatewayContainerSpec(
|
||||
input: GatewayContainerSpec,
|
||||
): Promise<ContainerSpec> {
|
||||
return {
|
||||
name: OPENCLAW_GATEWAY_CONTAINER_NAME,
|
||||
image: input.image,
|
||||
restart: 'unless-stopped',
|
||||
ports: [
|
||||
{
|
||||
hostIp: '127.0.0.1',
|
||||
hostPort: input.hostPort,
|
||||
containerPort: OPENCLAW_GATEWAY_CONTAINER_PORT,
|
||||
},
|
||||
],
|
||||
envFile: this.translateHostPath(input.envFilePath, input.hostHome),
|
||||
env: this.buildGatewayEnv(input),
|
||||
mounts: [{ source: GUEST_OPENCLAW_HOME, target: GATEWAY_CONTAINER_HOME }],
|
||||
addHosts: [await this.hostContainersInternalEntry()],
|
||||
health: {
|
||||
cmd: `curl -sf http://127.0.0.1:${OPENCLAW_GATEWAY_CONTAINER_PORT}/healthz`,
|
||||
interval: '30s',
|
||||
timeout: '10s',
|
||||
retries: 3,
|
||||
private async compose(args: string[], onLog?: LogFn): Promise<number> {
|
||||
const lines: string[] = []
|
||||
const command = ['podman', 'compose', ...args].join(' ')
|
||||
logger.info('Running OpenClaw compose command', {
|
||||
command,
|
||||
})
|
||||
const code = await this.podman.runCommand(['compose', ...args], {
|
||||
cwd: this.projectDir,
|
||||
env: { COMPOSE_PROJECT_NAME: OPENCLAW_COMPOSE_PROJECT_NAME },
|
||||
onOutput: (line) => {
|
||||
lines.push(line)
|
||||
onLog?.(line)
|
||||
},
|
||||
command: [
|
||||
'node',
|
||||
'dist/index.js',
|
||||
'gateway',
|
||||
'--bind',
|
||||
'lan',
|
||||
'--port',
|
||||
String(OPENCLAW_GATEWAY_CONTAINER_PORT),
|
||||
'--allow-unconfigured',
|
||||
],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
private async buildGatewayRunArgs(
|
||||
input: GatewayContainerSpec,
|
||||
): Promise<string[]> {
|
||||
const args = [
|
||||
'--env-file',
|
||||
this.translateHostPath(input.envFilePath, input.hostHome),
|
||||
'-v',
|
||||
`${GUEST_OPENCLAW_HOME}:${GATEWAY_CONTAINER_HOME}`,
|
||||
]
|
||||
for (const [key, value] of Object.entries(this.buildGatewayEnv(input))) {
|
||||
args.push('-e', `${key}=${value}`)
|
||||
if (code !== 0) {
|
||||
logger.error('OpenClaw compose command failed', {
|
||||
command,
|
||||
exitCode: code,
|
||||
output: lines,
|
||||
})
|
||||
} else {
|
||||
logger.info('OpenClaw compose command succeeded', {
|
||||
command,
|
||||
})
|
||||
}
|
||||
args.push('--add-host', await this.hostContainersInternalEntry())
|
||||
return args
|
||||
}
|
||||
|
||||
private async hostContainersInternalEntry(): Promise<string> {
|
||||
return `host.containers.internal:${await this.vm.getDefaultGateway()}`
|
||||
}
|
||||
|
||||
private buildGatewayEnv(input: GatewayContainerSpec): Record<string, string> {
|
||||
return {
|
||||
HOME: GATEWAY_CONTAINER_HOME,
|
||||
OPENCLAW_HOME: GATEWAY_CONTAINER_HOME,
|
||||
OPENCLAW_STATE_DIR: GATEWAY_STATE_DIR,
|
||||
OPENCLAW_NO_RESPAWN: '1',
|
||||
NODE_COMPILE_CACHE: '/var/tmp/openclaw-compile-cache',
|
||||
NODE_ENV: 'production',
|
||||
TZ: input.timezone,
|
||||
PATH: GATEWAY_PATH,
|
||||
NPM_CONFIG_PREFIX: GATEWAY_NPM_PREFIX,
|
||||
...(input.gatewayToken
|
||||
? { OPENCLAW_GATEWAY_TOKEN: input.gatewayToken }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
private translateHostPath(path: string, openclawHostDir: string): string {
|
||||
if (path === openclawHostDir) return GUEST_OPENCLAW_HOME
|
||||
if (path.startsWith(`${openclawHostDir}/`)) {
|
||||
return `${GUEST_OPENCLAW_HOME}${path.slice(openclawHostDir.length)}`
|
||||
}
|
||||
return hostPathToGuest(path)
|
||||
return code
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,10 +27,3 @@ export class OpenClawProtectedAgentError extends Error {
|
||||
this.name = 'OpenClawProtectedAgentError'
|
||||
}
|
||||
}
|
||||
|
||||
export class OpenClawSessionNotFoundError extends Error {
|
||||
constructor(public readonly sessionKey: string) {
|
||||
super(`OpenClaw session not found: ${sessionKey}`)
|
||||
this.name = 'OpenClawSessionNotFoundError'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,754 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* WebSocket client for the OpenClaw Gateway protocol.
|
||||
* Handles handshake (challenge → connect → hello-ok) with Ed25519 device
|
||||
* identity signing, JSON-RPC over WS, and auto-reconnect.
|
||||
* Used for agent CRUD and health — chat uses HTTP.
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto'
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { OPENCLAW_CONTAINER_HOME } from '@browseros/shared/constants/openclaw'
|
||||
import { logger } from '../../../lib/logger'
|
||||
|
||||
const RPC_TIMEOUT_MS = 15_000
|
||||
const SCOPES = [
|
||||
'operator.read',
|
||||
'operator.write',
|
||||
'operator.admin',
|
||||
'operator.approvals',
|
||||
'operator.pairing',
|
||||
]
|
||||
|
||||
interface DeviceIdentity {
|
||||
deviceId: string
|
||||
publicKeyPem: string
|
||||
privateKeyPem: string
|
||||
}
|
||||
|
||||
interface PendingRequest {
|
||||
resolve: (value: unknown) => void
|
||||
reject: (reason: Error) => void
|
||||
timer: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
interface WsFrame {
|
||||
type: 'req' | 'res' | 'event'
|
||||
id?: string
|
||||
method?: string
|
||||
params?: Record<string, unknown>
|
||||
ok?: boolean
|
||||
payload?: Record<string, unknown>
|
||||
error?: { message: string; code?: string }
|
||||
event?: string
|
||||
}
|
||||
|
||||
export type GatewayClientConnectionState =
|
||||
| 'idle'
|
||||
| 'connecting'
|
||||
| 'connected'
|
||||
| 'closed'
|
||||
| 'failed'
|
||||
|
||||
export interface GatewayHandshakeError {
|
||||
code?: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface OpenClawStreamEvent {
|
||||
type:
|
||||
| 'text-delta'
|
||||
| 'thinking'
|
||||
| 'tool-start'
|
||||
| 'tool-end'
|
||||
| 'tool-output'
|
||||
| 'lifecycle'
|
||||
| 'done'
|
||||
| 'error'
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface GatewayAgentEntry {
|
||||
agentId: string
|
||||
name: string
|
||||
workspace: string
|
||||
model?: string
|
||||
}
|
||||
|
||||
// ── Device Identity Helpers ─────────────────────────────────────────
|
||||
|
||||
function rawPublicKeyFromPem(pem: string): Buffer {
|
||||
const der = Buffer.from(
|
||||
pem.replace(/-----[^-]+-----/g, '').replace(/\s/g, ''),
|
||||
'base64',
|
||||
)
|
||||
return der.subarray(12)
|
||||
}
|
||||
|
||||
function signChallenge(
|
||||
device: DeviceIdentity,
|
||||
nonce: string,
|
||||
token: string,
|
||||
): { signature: string; signedAt: number; publicKey: string } {
|
||||
const signedAt = Date.now()
|
||||
const payload = `v3|${device.deviceId}|cli|cli|operator|${SCOPES.join(',')}|${signedAt}|${token}|${nonce}|${process.platform}|`
|
||||
const privateKey = crypto.createPrivateKey(device.privateKeyPem)
|
||||
const sig = crypto.sign(null, Buffer.from(payload, 'utf-8'), privateKey)
|
||||
|
||||
return {
|
||||
signature: sig.toString('base64url'),
|
||||
signedAt,
|
||||
publicKey: rawPublicKeyFromPem(device.publicKeyPem).toString('base64url'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a client Ed25519 identity and pre-seeds it into the gateway's
|
||||
* paired devices file so the gateway trusts it on next boot.
|
||||
* Must be called before compose up (or requires a restart after).
|
||||
*/
|
||||
export function ensureClientIdentity(openclawDir: string): DeviceIdentity {
|
||||
const identityPath = join(openclawDir, 'client-identity.json')
|
||||
|
||||
try {
|
||||
return JSON.parse(readFileSync(identityPath, 'utf-8'))
|
||||
} catch {
|
||||
// Generate new identity
|
||||
}
|
||||
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519')
|
||||
const publicKeyPem = publicKey
|
||||
.export({ type: 'spki', format: 'pem' })
|
||||
.toString()
|
||||
const privateKeyPem = privateKey
|
||||
.export({ type: 'pkcs8', format: 'pem' })
|
||||
.toString()
|
||||
|
||||
const rawPub = rawPublicKeyFromPem(publicKeyPem)
|
||||
const deviceId = crypto.createHash('sha256').update(rawPub).digest('hex')
|
||||
|
||||
const identity: DeviceIdentity = { deviceId, publicKeyPem, privateKeyPem }
|
||||
writeFileSync(identityPath, JSON.stringify(identity, null, 2), {
|
||||
mode: 0o600,
|
||||
})
|
||||
|
||||
seedPairedDevice(openclawDir, identity)
|
||||
logger.info('Generated client device identity and pre-seeded pairing')
|
||||
|
||||
return identity
|
||||
}
|
||||
|
||||
function seedPairedDevice(openclawDir: string, identity: DeviceIdentity): void {
|
||||
const devicesDir = join(openclawDir, 'devices')
|
||||
mkdirSync(devicesDir, { recursive: true })
|
||||
|
||||
const pairedPath = join(devicesDir, 'paired.json')
|
||||
let paired: Record<string, unknown> = {}
|
||||
try {
|
||||
paired = JSON.parse(readFileSync(pairedPath, 'utf-8'))
|
||||
} catch {
|
||||
// First time
|
||||
}
|
||||
|
||||
const rawPub = rawPublicKeyFromPem(identity.publicKeyPem)
|
||||
paired[identity.deviceId] = {
|
||||
deviceId: identity.deviceId,
|
||||
publicKey: rawPub.toString('base64url'),
|
||||
platform: process.platform,
|
||||
clientId: 'cli',
|
||||
clientMode: 'cli',
|
||||
role: 'operator',
|
||||
roles: ['operator'],
|
||||
scopes: SCOPES,
|
||||
pairedAt: Date.now(),
|
||||
label: 'browseros-server',
|
||||
}
|
||||
|
||||
writeFileSync(pairedPath, JSON.stringify(paired, null, 2), { mode: 0o600 })
|
||||
}
|
||||
|
||||
// ── Gateway Client ──────────────────────────────────────────────────
|
||||
|
||||
export class GatewayClient {
|
||||
private ws: WebSocket | null = null
|
||||
private _connected = false
|
||||
private pendingRequests = new Map<string, PendingRequest>()
|
||||
private device: DeviceIdentity | null = null
|
||||
private connectionState: GatewayClientConnectionState = 'idle'
|
||||
private lastHandshakeError: GatewayHandshakeError | null = null
|
||||
|
||||
constructor(
|
||||
private readonly port: number,
|
||||
private readonly token: string,
|
||||
private readonly openclawDir: string,
|
||||
private readonly version = '1.0.0',
|
||||
) {
|
||||
try {
|
||||
const identityPath = join(this.openclawDir, 'client-identity.json')
|
||||
this.device = JSON.parse(readFileSync(identityPath, 'utf-8'))
|
||||
} catch {
|
||||
logger.warn('Client device identity not found, WS auth may fail')
|
||||
}
|
||||
}
|
||||
|
||||
get isConnected(): boolean {
|
||||
return this._connected
|
||||
}
|
||||
|
||||
get state(): GatewayClientConnectionState {
|
||||
return this.connectionState
|
||||
}
|
||||
|
||||
get lastError(): GatewayHandshakeError | null {
|
||||
return this.lastHandshakeError
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.connectionState = 'connecting'
|
||||
this.lastHandshakeError = null
|
||||
logger.info('Connecting to OpenClaw Gateway WS', {
|
||||
port: this.port,
|
||||
hasDeviceIdentity: !!this.device,
|
||||
})
|
||||
this.ws = new WebSocket(`ws://127.0.0.1:${this.port}`, {
|
||||
headers: { Origin: `http://127.0.0.1:${this.port}` },
|
||||
} as unknown as string[])
|
||||
|
||||
let handshakeComplete = false
|
||||
let connectReqId: string | null = null
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
const frame = GatewayClient.parseFrame(event.data)
|
||||
if (!frame) return
|
||||
|
||||
if (!handshakeComplete) {
|
||||
if (frame.type === 'event' && frame.event === 'connect.challenge') {
|
||||
const nonce = (frame.payload as Record<string, unknown>)
|
||||
?.nonce as string
|
||||
logger.info('Received OpenClaw Gateway challenge', {
|
||||
hasNonce: !!nonce,
|
||||
hasDeviceIdentity: !!this.device,
|
||||
})
|
||||
connectReqId = globalThis.crypto.randomUUID()
|
||||
|
||||
const params: Record<string, unknown> = {
|
||||
minProtocol: 3,
|
||||
maxProtocol: 3,
|
||||
client: {
|
||||
id: 'cli',
|
||||
version: this.version,
|
||||
platform: process.platform,
|
||||
mode: 'cli',
|
||||
},
|
||||
role: 'operator',
|
||||
scopes: SCOPES,
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: {},
|
||||
auth: { token: this.token },
|
||||
locale: 'en-US',
|
||||
userAgent: `browseros-server/${this.version}`,
|
||||
}
|
||||
|
||||
if (this.device && nonce) {
|
||||
const signed = signChallenge(this.device, nonce, this.token)
|
||||
params.device = {
|
||||
id: this.device.deviceId,
|
||||
publicKey: signed.publicKey,
|
||||
signature: signed.signature,
|
||||
signedAt: signed.signedAt,
|
||||
nonce,
|
||||
}
|
||||
}
|
||||
|
||||
this.ws?.send(
|
||||
JSON.stringify({
|
||||
type: 'req',
|
||||
id: connectReqId,
|
||||
method: 'connect',
|
||||
params,
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (frame.type === 'res' && frame.id === connectReqId) {
|
||||
if (frame.ok) {
|
||||
handshakeComplete = true
|
||||
this._connected = true
|
||||
this.connectionState = 'connected'
|
||||
logger.info('Gateway WS connected')
|
||||
resolve()
|
||||
} else {
|
||||
const msg = frame.error?.message ?? 'Handshake failed'
|
||||
this.connectionState = 'failed'
|
||||
this.lastHandshakeError = {
|
||||
message: msg,
|
||||
code: frame.error?.code,
|
||||
}
|
||||
logger.error('Gateway WS handshake rejected', {
|
||||
error: msg,
|
||||
code: frame.error?.code,
|
||||
})
|
||||
reject(new Error(msg))
|
||||
}
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
this.resolvePendingRequest(frame)
|
||||
}
|
||||
|
||||
this.ws.onerror = (err) => {
|
||||
logger.error('Gateway WS socket error', {
|
||||
error: err instanceof Error ? err.message : 'unknown',
|
||||
handshakeComplete,
|
||||
})
|
||||
if (!handshakeComplete) {
|
||||
this.connectionState = 'failed'
|
||||
reject(
|
||||
new Error(
|
||||
`WS connection error: ${err instanceof Error ? err.message : 'unknown'}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
this.ws.onclose = () => {
|
||||
this._connected = false
|
||||
this.connectionState = 'closed'
|
||||
this.rejectAllPending('WebSocket closed')
|
||||
if (handshakeComplete) {
|
||||
logger.info('Gateway WS disconnected')
|
||||
}
|
||||
this.ws = null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this._connected = false
|
||||
this.connectionState = 'closed'
|
||||
this.rejectAllPending('Client disconnecting')
|
||||
if (this.ws) {
|
||||
this.ws.onclose = null
|
||||
this.ws.close()
|
||||
this.ws = null
|
||||
}
|
||||
}
|
||||
|
||||
// ── RPC ──────────────────────────────────────────────────────────────
|
||||
|
||||
async rpc<T = Record<string, unknown>>(
|
||||
method: string,
|
||||
params: Record<string, unknown> = {},
|
||||
): Promise<T> {
|
||||
if (!this._connected || !this.ws) {
|
||||
throw new Error('Gateway WS not connected')
|
||||
}
|
||||
const id = globalThis.crypto.randomUUID()
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingRequests.delete(id)
|
||||
reject(new Error(`RPC timeout: ${method}`))
|
||||
}, RPC_TIMEOUT_MS)
|
||||
|
||||
this.pendingRequests.set(id, {
|
||||
resolve: resolve as (value: unknown) => void,
|
||||
reject,
|
||||
timer,
|
||||
})
|
||||
|
||||
this.ws?.send(JSON.stringify({ type: 'req', id, method, params }))
|
||||
})
|
||||
}
|
||||
|
||||
// ── Agent Methods ────────────────────────────────────────────────────
|
||||
|
||||
async listAgents(): Promise<GatewayAgentEntry[]> {
|
||||
const result = await this.rpc<{
|
||||
agents: Array<{
|
||||
id: string
|
||||
name?: string
|
||||
workspace: string
|
||||
model?: string
|
||||
}>
|
||||
}>('agents.list')
|
||||
|
||||
return (result.agents ?? []).map((a) => ({
|
||||
agentId: a.id,
|
||||
name: a.name ?? a.id,
|
||||
workspace: a.workspace,
|
||||
model: a.model,
|
||||
}))
|
||||
}
|
||||
|
||||
async createAgent(input: {
|
||||
name: string
|
||||
workspace: string
|
||||
model?: string
|
||||
}): Promise<GatewayAgentEntry> {
|
||||
const result = await this.rpc<{
|
||||
agentId?: string
|
||||
id?: string
|
||||
name?: string
|
||||
workspace?: string
|
||||
model?: string
|
||||
}>('agents.create', input)
|
||||
|
||||
return {
|
||||
agentId: result.agentId ?? result.id ?? input.name,
|
||||
name: result.name ?? input.name,
|
||||
workspace: result.workspace ?? input.workspace,
|
||||
model: result.model ?? input.model,
|
||||
}
|
||||
}
|
||||
|
||||
async deleteAgent(agentId: string): Promise<void> {
|
||||
await this.rpc('agents.delete', { id: agentId })
|
||||
}
|
||||
|
||||
// ── Health ───────────────────────────────────────────────────────────
|
||||
|
||||
async getHealth(): Promise<Record<string, unknown>> {
|
||||
return this.rpc('health')
|
||||
}
|
||||
|
||||
// ── Chat Stream ─────────────────────────────────────────────────────
|
||||
|
||||
chatStream(
|
||||
agentId: string,
|
||||
sessionKey: string,
|
||||
message: string,
|
||||
): ReadableStream<OpenClawStreamEvent> {
|
||||
if (!this._connected) {
|
||||
throw new Error('Gateway WS not connected')
|
||||
}
|
||||
|
||||
const fullSessionKey = `agent:${agentId}:browseros-${sessionKey}`
|
||||
const idempotencyKey = globalThis.crypto.randomUUID()
|
||||
const streamClient = new GatewayClient(
|
||||
this.port,
|
||||
this.token,
|
||||
this.openclawDir,
|
||||
this.version,
|
||||
)
|
||||
|
||||
return new ReadableStream<OpenClawStreamEvent>({
|
||||
start: async (controller) => {
|
||||
try {
|
||||
await streamClient.connect()
|
||||
} catch (error) {
|
||||
controller.enqueue({
|
||||
type: 'error',
|
||||
data: {
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Gateway WS not connected',
|
||||
},
|
||||
})
|
||||
controller.close()
|
||||
return
|
||||
}
|
||||
|
||||
const ws = streamClient.ws
|
||||
if (!ws) {
|
||||
controller.enqueue({
|
||||
type: 'error',
|
||||
data: { message: 'Gateway WS not connected' },
|
||||
})
|
||||
controller.close()
|
||||
return
|
||||
}
|
||||
|
||||
const subscribeId = globalThis.crypto.randomUUID()
|
||||
const agentReqId = globalThis.crypto.randomUUID()
|
||||
let finished = false
|
||||
|
||||
const finish = (event?: OpenClawStreamEvent) => {
|
||||
if (finished) return
|
||||
finished = true
|
||||
if (event) controller.enqueue(event)
|
||||
controller.close()
|
||||
streamClient.disconnect()
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const frame = GatewayClient.parseFrame(event.data)
|
||||
if (!frame) return
|
||||
|
||||
if (
|
||||
this.handleChatStreamControlFrame(
|
||||
frame,
|
||||
subscribeId,
|
||||
agentReqId,
|
||||
finish,
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
this.handleChatStreamEventFrame(frame, controller, finish)
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
if (finished) return
|
||||
finish({
|
||||
type: 'error',
|
||||
data: { message: 'Gateway WS disconnected' },
|
||||
})
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
if (finished) return
|
||||
finish({
|
||||
type: 'error',
|
||||
data: { message: 'Gateway WS connection error' },
|
||||
})
|
||||
}
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'req',
|
||||
id: subscribeId,
|
||||
method: 'sessions.subscribe',
|
||||
params: { sessionKey: fullSessionKey },
|
||||
}),
|
||||
)
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'req',
|
||||
id: agentReqId,
|
||||
method: 'agent',
|
||||
params: {
|
||||
message,
|
||||
sessionKey: fullSessionKey,
|
||||
idempotencyKey,
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
cancel: () => {
|
||||
if (streamClient.ws?.readyState === WebSocket.OPEN) {
|
||||
streamClient.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'req',
|
||||
id: globalThis.crypto.randomUUID(),
|
||||
method: 'sessions.abort',
|
||||
params: { sessionKey: fullSessionKey },
|
||||
}),
|
||||
)
|
||||
}
|
||||
streamClient.disconnect()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
static agentWorkspace(name: string): string {
|
||||
return name === 'main'
|
||||
? `${OPENCLAW_CONTAINER_HOME}/workspace`
|
||||
: `${OPENCLAW_CONTAINER_HOME}/workspace-${name}`
|
||||
}
|
||||
|
||||
private static parseFrame(data: unknown): WsFrame | null {
|
||||
try {
|
||||
return JSON.parse(
|
||||
typeof data === 'string'
|
||||
? data
|
||||
: new TextDecoder().decode(data as ArrayBuffer),
|
||||
) as WsFrame
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private rejectAllPending(reason: string): void {
|
||||
for (const [id, pending] of this.pendingRequests) {
|
||||
clearTimeout(pending.timer)
|
||||
pending.reject(new Error(reason))
|
||||
this.pendingRequests.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
private resolvePendingRequest(frame: WsFrame): void {
|
||||
if (frame.type !== 'res' || !frame.id) return
|
||||
|
||||
const pending = this.pendingRequests.get(frame.id)
|
||||
if (!pending) return
|
||||
|
||||
this.pendingRequests.delete(frame.id)
|
||||
clearTimeout(pending.timer)
|
||||
if (frame.ok) {
|
||||
pending.resolve(frame.payload)
|
||||
} else {
|
||||
pending.reject(new Error(frame.error?.message ?? 'RPC error'))
|
||||
}
|
||||
}
|
||||
|
||||
private handleChatStreamControlFrame(
|
||||
frame: WsFrame,
|
||||
subscribeId: string,
|
||||
agentReqId: string,
|
||||
finish: (event?: OpenClawStreamEvent) => void,
|
||||
): boolean {
|
||||
if (frame.type !== 'res' || !frame.id) return false
|
||||
if (frame.id !== subscribeId && frame.id !== agentReqId) return false
|
||||
|
||||
if (!frame.ok) {
|
||||
finish({
|
||||
type: 'error',
|
||||
data: {
|
||||
message: frame.error?.message ?? 'RPC error',
|
||||
code: frame.error?.code,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private handleChatStreamEventFrame(
|
||||
frame: WsFrame,
|
||||
controller: ReadableStreamDefaultController<OpenClawStreamEvent>,
|
||||
finish: (event?: OpenClawStreamEvent) => void,
|
||||
): void {
|
||||
if (frame.type !== 'event' || !frame.event || !frame.payload) return
|
||||
|
||||
switch (frame.event) {
|
||||
case 'agent':
|
||||
this.handleAgentStreamEvent(frame.payload, controller)
|
||||
return
|
||||
case 'session.tool':
|
||||
this.handleSessionToolStreamEvent(frame.payload, controller)
|
||||
return
|
||||
case 'session.message':
|
||||
this.handleSessionMessageStreamEvent(frame.payload, controller)
|
||||
return
|
||||
case 'chat':
|
||||
this.handleChatCompletionEvent(frame.payload, finish)
|
||||
return
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private handleAgentStreamEvent(
|
||||
payload: Record<string, unknown>,
|
||||
controller: ReadableStreamDefaultController<OpenClawStreamEvent>,
|
||||
): void {
|
||||
const streamType = payload.stream as string | undefined
|
||||
const data = payload.data as Record<string, unknown> | undefined
|
||||
|
||||
if (streamType === 'assistant' && data?.delta) {
|
||||
controller.enqueue({
|
||||
type: 'text-delta',
|
||||
data: { text: data.delta },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (streamType === 'item' && data) {
|
||||
const phase = data.phase as string | undefined
|
||||
if (phase === 'start') {
|
||||
controller.enqueue({
|
||||
type: 'tool-start',
|
||||
data: {
|
||||
toolCallId: data.toolCallId ?? data.id,
|
||||
toolName: data.name ?? data.title,
|
||||
kind: data.kind,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (phase === 'end') {
|
||||
controller.enqueue({
|
||||
type: 'tool-end',
|
||||
data: {
|
||||
toolCallId: data.toolCallId ?? data.id,
|
||||
status: data.status,
|
||||
durationMs: data.durationMs,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (streamType === 'lifecycle') {
|
||||
controller.enqueue({
|
||||
type: 'lifecycle',
|
||||
data: { phase: data?.phase ?? payload.phase },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private handleSessionToolStreamEvent(
|
||||
payload: Record<string, unknown>,
|
||||
controller: ReadableStreamDefaultController<OpenClawStreamEvent>,
|
||||
): void {
|
||||
const toolData = (payload.data as Record<string, unknown>) ?? payload
|
||||
const phase = (toolData.phase as string) ?? (payload.phase as string)
|
||||
if (phase !== 'result') return
|
||||
|
||||
controller.enqueue({
|
||||
type: 'tool-output',
|
||||
data: {
|
||||
toolCallId: toolData.toolCallId,
|
||||
isError: toolData.isError ?? false,
|
||||
meta: toolData.meta,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private handleSessionMessageStreamEvent(
|
||||
payload: Record<string, unknown>,
|
||||
controller: ReadableStreamDefaultController<OpenClawStreamEvent>,
|
||||
): void {
|
||||
const message = payload.message as Record<string, unknown> | undefined
|
||||
if (message?.role !== 'assistant') return
|
||||
|
||||
const content = message.content as
|
||||
| Array<Record<string, unknown>>
|
||||
| undefined
|
||||
if (!content) return
|
||||
|
||||
for (const block of content) {
|
||||
if (block.type !== 'thinking') continue
|
||||
|
||||
const text =
|
||||
(block.thinking as string) ??
|
||||
(block.content as string) ??
|
||||
(block.text as string) ??
|
||||
''
|
||||
if (!text) continue
|
||||
|
||||
controller.enqueue({
|
||||
type: 'thinking',
|
||||
data: { text },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private handleChatCompletionEvent(
|
||||
payload: Record<string, unknown>,
|
||||
finish: (event?: OpenClawStreamEvent) => void,
|
||||
): void {
|
||||
if ((payload.state as string | undefined) !== 'final') return
|
||||
|
||||
finish({
|
||||
type: 'done',
|
||||
data: { text: (payload.text as string) ?? '' },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,581 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { OPENCLAW_CONTAINER_HOME } from '@browseros/shared/constants/openclaw'
|
||||
|
||||
type LogFn = (line: string) => void
|
||||
|
||||
interface ContainerExecutor {
|
||||
execInContainer(command: string[], onLog?: LogFn): Promise<number>
|
||||
}
|
||||
|
||||
export interface OpenClawConfigBatchEntry {
|
||||
path: string
|
||||
value: unknown
|
||||
}
|
||||
|
||||
interface RawAgentRecord {
|
||||
id: string
|
||||
name?: string
|
||||
workspace: string
|
||||
model?: string
|
||||
}
|
||||
|
||||
export interface OpenClawAgentRecord {
|
||||
agentId: string
|
||||
name: string
|
||||
workspace: string
|
||||
model?: string
|
||||
}
|
||||
|
||||
export interface OpenClawSessionEntry {
|
||||
key: string
|
||||
updatedAt: number
|
||||
sessionId: string
|
||||
agentId: string
|
||||
kind: string
|
||||
status?: string
|
||||
totalTokens?: number
|
||||
model?: string
|
||||
modelProvider?: string
|
||||
}
|
||||
|
||||
export interface OpenClawChatBlock {
|
||||
type: 'text' | 'toolCall' | 'thinking'
|
||||
text?: string
|
||||
name?: string
|
||||
arguments?: unknown
|
||||
thinking?: string
|
||||
}
|
||||
|
||||
export interface OpenClawChatMessage {
|
||||
role: 'user' | 'assistant' | 'toolResult'
|
||||
content: OpenClawChatBlock[]
|
||||
timestamp?: number
|
||||
usage?: { input: number; output: number }
|
||||
stopReason?: string
|
||||
toolName?: string
|
||||
toolCallId?: string
|
||||
isError?: boolean
|
||||
}
|
||||
|
||||
export class OpenClawCliClient {
|
||||
constructor(private readonly executor: ContainerExecutor) {}
|
||||
|
||||
async runOnboard(
|
||||
input: {
|
||||
acceptRisk?: boolean
|
||||
authChoice?: string
|
||||
customBaseUrl?: string
|
||||
customCompatibility?: 'anthropic' | 'openai-completions'
|
||||
customModelId?: string
|
||||
customProviderId?: string
|
||||
gatewayAuth?: 'none' | 'password' | 'token'
|
||||
gatewayBind?: 'auto' | 'custom' | 'lan' | 'loopback' | 'tailnet'
|
||||
gatewayPort?: number
|
||||
gatewayToken?: string
|
||||
gatewayTokenRefEnv?: string
|
||||
installDaemon?: boolean
|
||||
mode?: 'local' | 'remote'
|
||||
nonInteractive?: boolean
|
||||
reset?: boolean
|
||||
resetScope?: 'config' | 'config+creds+sessions' | 'full'
|
||||
secretInputMode?: 'plain' | 'ref'
|
||||
skipHealth?: boolean
|
||||
workspace?: string
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
const args = ['onboard']
|
||||
|
||||
if (input.nonInteractive) {
|
||||
args.push('--non-interactive')
|
||||
}
|
||||
if (input.mode) {
|
||||
args.push('--mode', input.mode)
|
||||
}
|
||||
if (input.workspace) {
|
||||
args.push('--workspace', input.workspace)
|
||||
}
|
||||
if (input.reset) {
|
||||
args.push('--reset')
|
||||
}
|
||||
if (input.resetScope) {
|
||||
args.push('--reset-scope', input.resetScope)
|
||||
}
|
||||
if (input.authChoice) {
|
||||
args.push('--auth-choice', input.authChoice)
|
||||
}
|
||||
if (input.secretInputMode) {
|
||||
args.push('--secret-input-mode', input.secretInputMode)
|
||||
}
|
||||
if (input.customBaseUrl) {
|
||||
args.push('--custom-base-url', input.customBaseUrl)
|
||||
}
|
||||
if (input.customModelId) {
|
||||
args.push('--custom-model-id', input.customModelId)
|
||||
}
|
||||
if (input.customProviderId) {
|
||||
args.push('--custom-provider-id', input.customProviderId)
|
||||
}
|
||||
if (input.customCompatibility) {
|
||||
args.push('--custom-compatibility', input.customCompatibility)
|
||||
}
|
||||
if (input.gatewayAuth) {
|
||||
args.push('--gateway-auth', input.gatewayAuth)
|
||||
}
|
||||
if (input.gatewayToken) {
|
||||
args.push('--gateway-token', input.gatewayToken)
|
||||
}
|
||||
if (input.gatewayTokenRefEnv) {
|
||||
args.push('--gateway-token-ref-env', input.gatewayTokenRefEnv)
|
||||
}
|
||||
if (input.gatewayPort) {
|
||||
args.push('--gateway-port', String(input.gatewayPort))
|
||||
}
|
||||
if (input.gatewayBind) {
|
||||
args.push('--gateway-bind', input.gatewayBind)
|
||||
}
|
||||
if (input.installDaemon === true) {
|
||||
args.push('--install-daemon')
|
||||
} else if (input.installDaemon === false) {
|
||||
args.push('--no-install-daemon')
|
||||
}
|
||||
if (input.skipHealth) {
|
||||
args.push('--skip-health')
|
||||
}
|
||||
if (input.acceptRisk) {
|
||||
args.push('--accept-risk')
|
||||
}
|
||||
|
||||
await this.runCommand(args)
|
||||
}
|
||||
|
||||
async setConfig(path: string, value: unknown): Promise<void> {
|
||||
await this.runCommand(['config', 'set', path, formatConfigValue(value)])
|
||||
}
|
||||
|
||||
async setConfigBatch(entries: OpenClawConfigBatchEntry[]): Promise<void> {
|
||||
await this.runCommand([
|
||||
'config',
|
||||
'set',
|
||||
'--batch-json',
|
||||
JSON.stringify(entries),
|
||||
])
|
||||
}
|
||||
|
||||
async getConfig(path: string): Promise<unknown> {
|
||||
const output = await this.runCommand(['config', 'get', path])
|
||||
return parseConfigValue(output)
|
||||
}
|
||||
|
||||
async validateConfig(): Promise<unknown> {
|
||||
const output = await this.runCommand(['config', 'validate', '--json'])
|
||||
return parseConfigValue(output)
|
||||
}
|
||||
|
||||
async setDefaultModel(model: string): Promise<void> {
|
||||
await this.runCommand(['models', 'set', model])
|
||||
}
|
||||
|
||||
async listAgents(): Promise<OpenClawAgentRecord[]> {
|
||||
const records = await this.runAgentListCommand()
|
||||
const agents = Array.isArray(records) ? records : (records.agents ?? [])
|
||||
return agents.map((record) => ({
|
||||
agentId: record.id,
|
||||
name: record.name ?? record.id,
|
||||
workspace: record.workspace,
|
||||
model: record.model,
|
||||
}))
|
||||
}
|
||||
|
||||
async createAgent(input: {
|
||||
name: string
|
||||
model?: string
|
||||
}): Promise<OpenClawAgentRecord> {
|
||||
const workspace = this.agentWorkspace(input.name)
|
||||
const args = ['agents', 'add', input.name, '--workspace', workspace]
|
||||
|
||||
if (input.model) {
|
||||
args.push('--model', input.model)
|
||||
}
|
||||
|
||||
args.push('--non-interactive', '--json')
|
||||
await this.runCommand(args)
|
||||
|
||||
const agents = await this.listAgents()
|
||||
const agent = agents.find((entry) => entry.agentId === input.name)
|
||||
if (!agent) {
|
||||
throw new Error(`Created agent ${input.name} was not found in agent list`)
|
||||
}
|
||||
|
||||
return agent
|
||||
}
|
||||
|
||||
async deleteAgent(agentId: string): Promise<void> {
|
||||
await this.runCommand(['agents', 'delete', agentId, '--force', '--json'])
|
||||
}
|
||||
|
||||
async probe(): Promise<void> {
|
||||
await this.listAgents()
|
||||
}
|
||||
|
||||
async listSessions(agentId?: string): Promise<OpenClawSessionEntry[]> {
|
||||
const args = ['sessions', '--json']
|
||||
if (agentId) {
|
||||
args.push('--agent', agentId)
|
||||
} else {
|
||||
args.push('--all-agents')
|
||||
}
|
||||
|
||||
const output = await this.runCommand(args)
|
||||
const parsed = parseFirstMatchingJson<
|
||||
{ sessions?: unknown[]; count?: number } | unknown[]
|
||||
>(output, isSessionListPayload)
|
||||
|
||||
if (parsed === null) {
|
||||
throw new Error(
|
||||
`Failed to parse OpenClaw sessions output: ${output.slice(0, 200)}`,
|
||||
)
|
||||
}
|
||||
|
||||
const entries = Array.isArray(parsed) ? parsed : (parsed.sessions ?? [])
|
||||
return entries.map(toSessionEntry)
|
||||
}
|
||||
|
||||
async getChatHistory(sessionKey: string): Promise<OpenClawChatMessage[]> {
|
||||
const output = await this.runCommand([
|
||||
'gateway',
|
||||
'call',
|
||||
'chat.history',
|
||||
'--params',
|
||||
JSON.stringify({ sessionKey }),
|
||||
'--json',
|
||||
])
|
||||
|
||||
const parsed = parseFirstMatchingJson<{ messages?: unknown[] }>(
|
||||
output,
|
||||
(value) => isPlainObject(value) && 'messages' in value,
|
||||
)
|
||||
|
||||
if (parsed === null) {
|
||||
throw new Error(
|
||||
`Failed to parse OpenClaw chat history output: ${output.slice(0, 200)}`,
|
||||
)
|
||||
}
|
||||
|
||||
return (parsed.messages ?? []).map(toChatMessage)
|
||||
}
|
||||
|
||||
private agentWorkspace(name: string): string {
|
||||
return name === 'main'
|
||||
? `${OPENCLAW_CONTAINER_HOME}/workspace`
|
||||
: `${OPENCLAW_CONTAINER_HOME}/workspace-${name}`
|
||||
}
|
||||
|
||||
private async runCommand(args: string[]): Promise<string> {
|
||||
const output: string[] = []
|
||||
const command = ['node', 'dist/index.js', ...args]
|
||||
const exitCode = await this.executor.execInContainer(command, (line) => {
|
||||
output.push(line)
|
||||
})
|
||||
|
||||
if (exitCode !== 0) {
|
||||
const detail = output.join('\n').trim()
|
||||
throw new Error(
|
||||
detail || `OpenClaw command failed (${args.slice(0, 2).join(' ')})`,
|
||||
)
|
||||
}
|
||||
|
||||
return output.join('\n').trim()
|
||||
}
|
||||
|
||||
private async runAgentListCommand(): Promise<
|
||||
RawAgentRecord[] | { agents?: RawAgentRecord[] }
|
||||
> {
|
||||
const output = await this.runCommand(['agents', 'list', '--json'])
|
||||
return parseAgentListOutput(output)
|
||||
}
|
||||
}
|
||||
|
||||
function formatConfigValue(value: unknown): string {
|
||||
if (typeof value === 'string') return value
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
function parseConfigValue(output: string): unknown {
|
||||
const parsed = selectConfigJson<unknown>(output)
|
||||
return parsed ?? output
|
||||
}
|
||||
|
||||
function parseAgentListOutput(
|
||||
output: string,
|
||||
): RawAgentRecord[] | { agents?: RawAgentRecord[] } {
|
||||
const parsed = parseFirstMatchingJson<
|
||||
RawAgentRecord[] | { agents?: RawAgentRecord[] }
|
||||
>(output, isAgentListPayload)
|
||||
if (parsed !== null) return parsed
|
||||
|
||||
throw new Error(
|
||||
`Failed to parse OpenClaw JSON output: ${output.slice(0, 200)}`,
|
||||
)
|
||||
}
|
||||
|
||||
function parseFirstMatchingJson<T>(
|
||||
output: string,
|
||||
predicate?: (value: unknown) => boolean,
|
||||
): T | null {
|
||||
const candidates = collectJsonCandidates(output)
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const parsed = tryParseJson<T>(candidate)
|
||||
if (parsed === null) continue
|
||||
if (predicate && !predicate(parsed)) continue
|
||||
return parsed
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function selectConfigJson<T>(output: string): T | null {
|
||||
const candidates = collectJsonCandidates(output)
|
||||
const parsedCandidates: Array<{ text: string; value: T }> = []
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const parsed = tryParseJson<T>(candidate)
|
||||
if (parsed === null) continue
|
||||
if (isStructuredLogPayload(parsed)) continue
|
||||
parsedCandidates.push({ text: candidate, value: parsed })
|
||||
}
|
||||
|
||||
if (parsedCandidates.length === 0) return null
|
||||
|
||||
return parsedCandidates.reduce((best, candidate) =>
|
||||
candidate.text.length > best.text.length ? candidate : best,
|
||||
).value
|
||||
}
|
||||
|
||||
function collectJsonCandidates(output: string): string[] {
|
||||
const candidates = [output.trim()]
|
||||
|
||||
for (const line of output.split(/\r?\n/)) {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed) candidates.push(trimmed)
|
||||
}
|
||||
|
||||
for (let index = 0; index < output.length; index += 1) {
|
||||
const char = output[index]
|
||||
if (char !== '[' && char !== '{') continue
|
||||
const extracted = extractJsonSubstring(output, index)
|
||||
if (extracted) {
|
||||
candidates.push(extracted)
|
||||
}
|
||||
}
|
||||
|
||||
return candidates
|
||||
}
|
||||
|
||||
function extractJsonSubstring(
|
||||
output: string,
|
||||
startIndex: number,
|
||||
): string | null {
|
||||
const opening = output[startIndex]
|
||||
const closing = opening === '{' ? '}' : ']'
|
||||
const stack: string[] = [closing]
|
||||
let inString = false
|
||||
let escaped = false
|
||||
|
||||
for (let index = startIndex + 1; index < output.length; index += 1) {
|
||||
const char = output[index]
|
||||
|
||||
if (inString) {
|
||||
if (escaped) {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if (char === '\\') {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
inString = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '"') {
|
||||
inString = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '{') {
|
||||
stack.push('}')
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '[') {
|
||||
stack.push(']')
|
||||
continue
|
||||
}
|
||||
|
||||
const expectedClosing = stack[stack.length - 1]
|
||||
if (char === expectedClosing) {
|
||||
stack.pop()
|
||||
if (stack.length === 0) {
|
||||
return output.slice(startIndex, index + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function tryParseJson<T>(value: string): T | null {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
|
||||
try {
|
||||
return JSON.parse(trimmed) as T
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function isAgentListPayload(
|
||||
value: unknown,
|
||||
): value is RawAgentRecord[] | { agents?: RawAgentRecord[] } {
|
||||
if (Array.isArray(value)) {
|
||||
return value.every(isRawAgentRecord)
|
||||
}
|
||||
|
||||
if (!isPlainObject(value)) return false
|
||||
|
||||
if (!('agents' in value)) return false
|
||||
|
||||
const agents = (value as { agents?: unknown }).agents
|
||||
return (
|
||||
agents === undefined ||
|
||||
(Array.isArray(agents) && agents.every(isRawAgentRecord))
|
||||
)
|
||||
}
|
||||
|
||||
function isRawAgentRecord(value: unknown): value is RawAgentRecord {
|
||||
return (
|
||||
isPlainObject(value) &&
|
||||
typeof value.id === 'string' &&
|
||||
typeof value.workspace === 'string' &&
|
||||
(value.name === undefined || typeof value.name === 'string') &&
|
||||
(value.model === undefined || typeof value.model === 'string')
|
||||
)
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isStructuredLogPayload(value: unknown): boolean {
|
||||
if (!isPlainObject(value)) return false
|
||||
|
||||
return (
|
||||
typeof value.level === 'string' &&
|
||||
(typeof value.message === 'string' || typeof value.msg === 'string')
|
||||
)
|
||||
}
|
||||
|
||||
function isSessionListPayload(value: unknown): boolean {
|
||||
if (Array.isArray(value)) return true
|
||||
if (!isPlainObject(value)) return false
|
||||
return 'sessions' in value || 'count' in value
|
||||
}
|
||||
|
||||
function toSessionEntry(raw: unknown): OpenClawSessionEntry {
|
||||
const record = isPlainObject(raw) ? raw : {}
|
||||
return {
|
||||
key: String(record.key ?? ''),
|
||||
updatedAt: typeof record.updatedAt === 'number' ? record.updatedAt : 0,
|
||||
sessionId: String(record.sessionId ?? ''),
|
||||
agentId: String(record.agentId ?? ''),
|
||||
kind: String(record.kind ?? ''),
|
||||
status: typeof record.status === 'string' ? record.status : undefined,
|
||||
totalTokens:
|
||||
typeof record.totalTokens === 'number' ? record.totalTokens : undefined,
|
||||
model: typeof record.model === 'string' ? record.model : undefined,
|
||||
modelProvider:
|
||||
typeof record.modelProvider === 'string'
|
||||
? record.modelProvider
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function toChatMessage(raw: unknown): OpenClawChatMessage {
|
||||
const record = isPlainObject(raw) ? raw : {}
|
||||
const role = isOpenClawMessageRole(record.role) ? record.role : 'assistant'
|
||||
const message: OpenClawChatMessage = {
|
||||
role,
|
||||
content: toChatBlocks(record.content),
|
||||
}
|
||||
|
||||
if (typeof record.timestamp === 'number') message.timestamp = record.timestamp
|
||||
if (isPlainObject(record.usage)) {
|
||||
const { input, output } = record.usage
|
||||
if (typeof input === 'number' && typeof output === 'number') {
|
||||
message.usage = { input, output }
|
||||
}
|
||||
}
|
||||
if (typeof record.stopReason === 'string') {
|
||||
message.stopReason = record.stopReason
|
||||
}
|
||||
if (typeof record.toolName === 'string') message.toolName = record.toolName
|
||||
if (typeof record.toolCallId === 'string') {
|
||||
message.toolCallId = record.toolCallId
|
||||
}
|
||||
if (typeof record.isError === 'boolean') message.isError = record.isError
|
||||
|
||||
return message
|
||||
}
|
||||
|
||||
function toChatBlocks(content: unknown): OpenClawChatBlock[] {
|
||||
if (typeof content === 'string') {
|
||||
return [{ type: 'text', text: content }]
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) return []
|
||||
|
||||
const blocks: OpenClawChatBlock[] = []
|
||||
for (const rawBlock of content) {
|
||||
if (!isPlainObject(rawBlock)) continue
|
||||
|
||||
if (rawBlock.type === 'toolCall') {
|
||||
const block: OpenClawChatBlock = { type: 'toolCall' }
|
||||
if (typeof rawBlock.name === 'string') block.name = rawBlock.name
|
||||
if (rawBlock.arguments !== undefined) {
|
||||
block.arguments = rawBlock.arguments
|
||||
}
|
||||
blocks.push(block)
|
||||
continue
|
||||
}
|
||||
|
||||
if (rawBlock.type === 'thinking') {
|
||||
const block: OpenClawChatBlock = { type: 'thinking' }
|
||||
if (typeof rawBlock.thinking === 'string') {
|
||||
block.thinking = rawBlock.thinking
|
||||
}
|
||||
blocks.push(block)
|
||||
continue
|
||||
}
|
||||
|
||||
const block: OpenClawChatBlock = { type: 'text' }
|
||||
if (typeof rawBlock.text === 'string') block.text = rawBlock.text
|
||||
blocks.push(block)
|
||||
}
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
function isOpenClawMessageRole(
|
||||
value: unknown,
|
||||
): value is OpenClawChatMessage['role'] {
|
||||
return value === 'user' || value === 'assistant' || value === 'toolResult'
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import type {
|
||||
OpenClawCliProvider,
|
||||
OpenClawCliProviderAuthStatus,
|
||||
} from './types'
|
||||
|
||||
const CLAUDE_CLI_MODELS = [
|
||||
'claude-sonnet-4-6',
|
||||
'claude-opus-4-6',
|
||||
'claude-haiku-4-5',
|
||||
] as const
|
||||
|
||||
// `claude auth status` emits JSON on both the logged-in (exit 0) and
|
||||
// not-logged-in (exit 1) paths. The caller passes us stdout alone —
|
||||
// the exec layer separates stdout and stderr so no extraction or
|
||||
// stripping of nerdctl noise is needed.
|
||||
interface ClaudeAuthStatusPayload {
|
||||
loggedIn?: boolean
|
||||
email?: string
|
||||
subscriptionType?: string
|
||||
}
|
||||
|
||||
function parseClaudeAuthStatus(
|
||||
stdout: string,
|
||||
exitCode: number,
|
||||
): OpenClawCliProviderAuthStatus {
|
||||
const trimmed = stdout.trim()
|
||||
|
||||
// Binary missing: claude isn't installed / not on PATH.
|
||||
if (exitCode === 127 || !trimmed) {
|
||||
return { installed: false, loggedIn: false }
|
||||
}
|
||||
|
||||
let payload: ClaudeAuthStatusPayload
|
||||
try {
|
||||
payload = JSON.parse(trimmed) as ClaudeAuthStatusPayload
|
||||
} catch {
|
||||
return {
|
||||
installed: true,
|
||||
loggedIn: false,
|
||||
error: `Unexpected claude auth status output: ${trimmed.slice(0, 200)}`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
installed: true,
|
||||
loggedIn: !!payload.loggedIn,
|
||||
accountLabel: payload.email,
|
||||
subscriptionLabel: payload.subscriptionType,
|
||||
}
|
||||
}
|
||||
|
||||
export const CLAUDE_CLI_PROVIDER: OpenClawCliProvider = {
|
||||
id: 'claude-cli',
|
||||
displayName: 'Anthropic Claude CLI',
|
||||
description: 'Uses your Claude.ai subscription via the Claude Code CLI',
|
||||
npmPackage: '@anthropic-ai/claude-code',
|
||||
npmPackageVersion: '2.1.119',
|
||||
binary: 'claude',
|
||||
authStatusCommand: ['claude', 'auth', 'status'],
|
||||
// `claude auth login` in 2.1.x silently discards stdin. The REPL's
|
||||
// `/login` slash command, launched from a fresh `claude` invocation,
|
||||
// does accept a pasted token.
|
||||
authLoginCommand: 'claude /login',
|
||||
models: CLAUDE_CLI_MODELS,
|
||||
parseAuthStatus: parseClaudeAuthStatus,
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Registry of OpenClaw CLI-backed providers. Add entries here as we
|
||||
* enable more (Gemini CLI, Codex CLI, etc.).
|
||||
*/
|
||||
|
||||
import { CLAUDE_CLI_PROVIDER } from './claude-cli'
|
||||
import type { OpenClawCliProvider } from './types'
|
||||
|
||||
export const OPENCLAW_CLI_PROVIDERS: readonly OpenClawCliProvider[] = [
|
||||
CLAUDE_CLI_PROVIDER,
|
||||
]
|
||||
|
||||
export function getOpenClawCliProvider(
|
||||
id: string,
|
||||
): OpenClawCliProvider | undefined {
|
||||
return OPENCLAW_CLI_PROVIDERS.find((provider) => provider.id === id)
|
||||
}
|
||||
|
||||
export function isOpenClawCliProviderId(id: string): boolean {
|
||||
return OPENCLAW_CLI_PROVIDERS.some((provider) => provider.id === id)
|
||||
}
|
||||
|
||||
export function buildOpenClawCliProviderModelRef(
|
||||
providerId: string,
|
||||
modelId: string,
|
||||
): string {
|
||||
return `${providerId}/${modelId}`
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* OpenClaw CLI-backed provider registry types.
|
||||
*
|
||||
* A "CLI provider" is a tool that runs inside the OpenClaw gateway
|
||||
* container (e.g. Claude Code CLI, Gemini CLI). OpenClaw spawns the
|
||||
* binary as a subprocess when the active model is prefixed with the
|
||||
* provider id — so our job is to install the tool and surface its
|
||||
* auth status to the user. No Anthropic/OpenRouter-style API key.
|
||||
*/
|
||||
|
||||
export interface OpenClawCliProviderAuthStatus {
|
||||
installed: boolean
|
||||
loggedIn: boolean
|
||||
accountLabel?: string
|
||||
subscriptionLabel?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface OpenClawCliProvider {
|
||||
id: string
|
||||
displayName: string
|
||||
description: string
|
||||
npmPackage: string
|
||||
// Pinned package version. npm installs go through argv directly
|
||||
// (no shell), so `@latest` drift can't silently ship through.
|
||||
npmPackageVersion: string
|
||||
binary: string
|
||||
authStatusCommand: string[]
|
||||
authLoginCommand: string
|
||||
models: readonly string[]
|
||||
parseAuthStatus: (
|
||||
stdout: string,
|
||||
exitCode: number,
|
||||
) => OpenClawCliProviderAuthStatus
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Pure functions for building OpenClaw bootstrap configuration.
|
||||
* Config is write-once at setup — agent CRUD uses WS RPC, not config edits.
|
||||
*/
|
||||
|
||||
import {
|
||||
OPENCLAW_CONTAINER_HOME,
|
||||
OPENCLAW_GATEWAY_PORT,
|
||||
} from '@browseros/shared/constants/openclaw'
|
||||
import { DEFAULT_PORTS } from '@browseros/shared/constants/ports'
|
||||
|
||||
const OPENCLAW_IMAGE = 'ghcr.io/openclaw/openclaw:latest'
|
||||
|
||||
export const PROVIDER_ENV_MAP: Record<string, string> = {
|
||||
anthropic: 'ANTHROPIC_API_KEY',
|
||||
openai: 'OPENAI_API_KEY',
|
||||
google: 'GEMINI_API_KEY',
|
||||
openrouter: 'OPENROUTER_API_KEY',
|
||||
moonshot: 'MOONSHOT_API_KEY',
|
||||
groq: 'GROQ_API_KEY',
|
||||
mistral: 'MISTRAL_API_KEY',
|
||||
}
|
||||
|
||||
export interface OpenClawProviderInput {
|
||||
providerType?: string
|
||||
providerName?: string
|
||||
baseUrl?: string
|
||||
modelId?: string
|
||||
apiKey?: string
|
||||
}
|
||||
|
||||
export interface BootstrapConfigInput {
|
||||
gatewayPort: number
|
||||
gatewayToken: string
|
||||
browserosServerPort?: number
|
||||
providerType?: string
|
||||
providerName?: string
|
||||
baseUrl?: string
|
||||
modelId?: string
|
||||
}
|
||||
|
||||
export interface EnvFileInput {
|
||||
image?: string
|
||||
port?: number
|
||||
token: string
|
||||
configDir: string
|
||||
timezone?: string
|
||||
providerKeys?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface ResolvedProviderConfig {
|
||||
model?: string
|
||||
providerKeys: Record<string, string>
|
||||
models?: {
|
||||
mode: 'merge'
|
||||
providers: Record<string, Record<string, unknown>>
|
||||
}
|
||||
}
|
||||
|
||||
function hasBuiltinProvider(providerType?: string): providerType is string {
|
||||
return !!providerType && providerType in PROVIDER_ENV_MAP
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenRouter's public slugs use dots for version numbers
|
||||
* (e.g. `anthropic/claude-haiku-4.5`), but openclaw's model registry expects
|
||||
* dashes (`claude-haiku-4-5`). Passing the dotted form makes openclaw fail
|
||||
* the registry lookup silently and the agent turn completes with zero
|
||||
* payloads. Rewrite dots to dashes for openrouter model ids only.
|
||||
*/
|
||||
function normalizeBuiltinModelId(
|
||||
providerType: string,
|
||||
modelId: string,
|
||||
): string {
|
||||
if (providerType !== 'openrouter') return modelId
|
||||
return modelId.replace(/\./g, '-')
|
||||
}
|
||||
|
||||
export function deriveOpenClawProviderId(providerInput: {
|
||||
providerType?: string
|
||||
providerName?: string
|
||||
baseUrl?: string
|
||||
}): string {
|
||||
const source =
|
||||
providerInput.providerName?.trim() ||
|
||||
providerInput.baseUrl?.trim() ||
|
||||
providerInput.providerType?.trim() ||
|
||||
'custom-provider'
|
||||
|
||||
const candidate = source
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
|
||||
return candidate || 'custom-provider'
|
||||
}
|
||||
|
||||
export function deriveOpenClawApiKeyEnvVar(providerId: string): string {
|
||||
return `${providerId.toUpperCase().replace(/-/g, '_')}_API_KEY`
|
||||
}
|
||||
|
||||
export function resolveProviderConfig(
|
||||
input: OpenClawProviderInput,
|
||||
): ResolvedProviderConfig {
|
||||
if (!input.providerType) {
|
||||
return { providerKeys: {} }
|
||||
}
|
||||
|
||||
if (hasBuiltinProvider(input.providerType)) {
|
||||
const providerKeys: Record<string, string> = {}
|
||||
if (input.apiKey) {
|
||||
providerKeys[PROVIDER_ENV_MAP[input.providerType]] = input.apiKey
|
||||
}
|
||||
|
||||
const normalizedModelId = input.modelId
|
||||
? normalizeBuiltinModelId(input.providerType, input.modelId)
|
||||
: undefined
|
||||
|
||||
return {
|
||||
providerKeys,
|
||||
model: normalizedModelId
|
||||
? `${input.providerType}/${normalizedModelId}`
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
if (!input.baseUrl) {
|
||||
return { providerKeys: {} }
|
||||
}
|
||||
|
||||
const providerId = deriveOpenClawProviderId(input)
|
||||
const apiKeyEnvVar = deriveOpenClawApiKeyEnvVar(providerId)
|
||||
const providerKeys: Record<string, string> = {}
|
||||
|
||||
if (input.apiKey) {
|
||||
providerKeys[apiKeyEnvVar] = input.apiKey
|
||||
}
|
||||
|
||||
const providerConfig: Record<string, unknown> = {
|
||||
baseUrl: input.baseUrl,
|
||||
apiKey: `\${${apiKeyEnvVar}}`,
|
||||
api: 'openai-completions',
|
||||
}
|
||||
|
||||
if (input.modelId) {
|
||||
providerConfig.models = [{ id: input.modelId, name: input.modelId }]
|
||||
}
|
||||
|
||||
return {
|
||||
providerKeys,
|
||||
model: input.modelId ? `${providerId}/${input.modelId}` : undefined,
|
||||
models: {
|
||||
mode: 'merge',
|
||||
providers: {
|
||||
[providerId]: providerConfig,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function buildBootstrapConfig(
|
||||
input: BootstrapConfigInput,
|
||||
): Record<string, unknown> {
|
||||
const serverPort = input.browserosServerPort ?? DEFAULT_PORTS.server
|
||||
const provider = resolveProviderConfig(input)
|
||||
|
||||
const defaults: Record<string, unknown> = {
|
||||
workspace: `${OPENCLAW_CONTAINER_HOME}/workspace`,
|
||||
timeoutSeconds: 4200,
|
||||
thinkingDefault: 'adaptive',
|
||||
}
|
||||
|
||||
if (provider.model) {
|
||||
defaults.model = { primary: provider.model }
|
||||
}
|
||||
const config: Record<string, unknown> = {
|
||||
gateway: {
|
||||
mode: 'local',
|
||||
port: input.gatewayPort,
|
||||
bind: 'lan',
|
||||
auth: { mode: 'token', token: input.gatewayToken },
|
||||
reload: { mode: 'restart' },
|
||||
controlUi: {
|
||||
allowInsecureAuth: true,
|
||||
allowedOrigins: [
|
||||
`http://127.0.0.1:${input.gatewayPort}`,
|
||||
`http://localhost:${input.gatewayPort}`,
|
||||
],
|
||||
},
|
||||
http: {
|
||||
endpoints: {
|
||||
chatCompletions: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: { defaults },
|
||||
tools: {
|
||||
profile: 'full',
|
||||
web: {
|
||||
search: { provider: 'duckduckgo', enabled: true },
|
||||
},
|
||||
exec: {
|
||||
host: 'gateway',
|
||||
security: 'full',
|
||||
ask: 'off',
|
||||
},
|
||||
},
|
||||
cron: { enabled: true },
|
||||
hooks: {
|
||||
internal: {
|
||||
enabled: true,
|
||||
entries: {
|
||||
'boot-md': { enabled: true },
|
||||
'bootstrap-extra-files': { enabled: true },
|
||||
'session-memory': { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
mcp: {
|
||||
servers: {
|
||||
browseros: {
|
||||
url: `http://host.containers.internal:${serverPort}/mcp`,
|
||||
transport: 'streamable-http',
|
||||
},
|
||||
},
|
||||
},
|
||||
approvals: {
|
||||
exec: { enabled: false },
|
||||
},
|
||||
skills: {
|
||||
install: { nodeManager: 'bun' },
|
||||
},
|
||||
}
|
||||
|
||||
if (provider.models) {
|
||||
config.models = provider.models
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
config.logging = { level: 'debug', consoleLevel: 'debug' }
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
export function buildEnvFile(input: EnvFileInput): string {
|
||||
const lines: string[] = [
|
||||
`OPENCLAW_IMAGE=${input.image ?? OPENCLAW_IMAGE}`,
|
||||
`OPENCLAW_GATEWAY_PORT=${input.port ?? OPENCLAW_GATEWAY_PORT}`,
|
||||
`OPENCLAW_GATEWAY_TOKEN=${input.token}`,
|
||||
`OPENCLAW_CONFIG_DIR=${input.configDir}`,
|
||||
`TZ=${input.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone}`,
|
||||
]
|
||||
|
||||
if (input.providerKeys) {
|
||||
for (const [key, value] of Object.entries(input.providerKeys)) {
|
||||
lines.push(`${key}=${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
|
||||
export function resolveProviderKeys(
|
||||
input: OpenClawProviderInput,
|
||||
): Record<string, string> {
|
||||
return resolveProviderConfig(input).providerKeys
|
||||
}
|
||||
|
||||
export function resolveProviderModel(
|
||||
input: OpenClawProviderInput,
|
||||
): string | undefined {
|
||||
return resolveProviderConfig(input).model
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { join } from 'node:path'
|
||||
|
||||
const STATE_DIR_NAME = '.openclaw'
|
||||
|
||||
export function getOpenClawStateDir(openclawDir: string): string {
|
||||
return join(openclawDir, STATE_DIR_NAME)
|
||||
}
|
||||
|
||||
export function getOpenClawStateConfigPath(openclawDir: string): string {
|
||||
return join(getOpenClawStateDir(openclawDir), 'openclaw.json')
|
||||
}
|
||||
|
||||
export function getOpenClawStateEnvPath(openclawDir: string): string {
|
||||
return join(getOpenClawStateDir(openclawDir), '.env')
|
||||
}
|
||||
|
||||
export function getHostWorkspaceDir(
|
||||
openclawDir: string,
|
||||
agentName: string,
|
||||
): string {
|
||||
return join(
|
||||
getOpenClawStateDir(openclawDir),
|
||||
agentName === 'main' ? 'workspace' : `workspace-${agentName}`,
|
||||
)
|
||||
}
|
||||
|
||||
export function mergeEnvContent(
|
||||
current: string,
|
||||
updates: Record<string, string>,
|
||||
): { changed: boolean; content: string } {
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return {
|
||||
changed: false,
|
||||
content: normalizeEnvContent(current),
|
||||
}
|
||||
}
|
||||
|
||||
const lines = current === '' ? [] : current.replace(/\r\n/g, '\n').split('\n')
|
||||
const nextLines = [...lines]
|
||||
let changed = false
|
||||
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
const replacement = `${key}=${value}`
|
||||
const index = nextLines.findIndex((line) => line.startsWith(`${key}=`))
|
||||
if (index === -1) {
|
||||
nextLines.push(replacement)
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
if (nextLines[index] === replacement) {
|
||||
continue
|
||||
}
|
||||
nextLines[index] = replacement
|
||||
changed = true
|
||||
}
|
||||
|
||||
const content = normalizeEnvContent(nextLines.join('\n'))
|
||||
return {
|
||||
changed: changed || content !== normalizeEnvContent(current),
|
||||
content,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeEnvContent(content: string): string {
|
||||
const trimmed = content.trim()
|
||||
return trimmed ? `${trimmed}\n` : ''
|
||||
}
|
||||
@@ -1,487 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { createParser, type EventSourceMessage } from 'eventsource-parser'
|
||||
import { OpenClawSessionNotFoundError } from './errors'
|
||||
import type { OpenClawStreamEvent } from './openclaw-types'
|
||||
|
||||
export interface OpenClawChatHistoryMessage {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface OpenClawChatRequest {
|
||||
agentId: string
|
||||
sessionKey: string
|
||||
message: string
|
||||
history?: OpenClawChatHistoryMessage[]
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export interface OpenClawSessionHistoryMessage {
|
||||
role: 'user' | 'assistant' | 'system' | 'tool'
|
||||
content: string
|
||||
messageId?: string
|
||||
messageSeq?: number
|
||||
timestamp?: number
|
||||
}
|
||||
|
||||
export interface OpenClawSessionHistory {
|
||||
sessionKey: string
|
||||
messages: OpenClawSessionHistoryMessage[]
|
||||
cursor?: string | null
|
||||
hasMore?: boolean
|
||||
truncated?: boolean
|
||||
}
|
||||
|
||||
export interface OpenClawSessionHistoryInput {
|
||||
limit?: number
|
||||
cursor?: string
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export type OpenClawSessionHistoryEvent =
|
||||
| { type: 'history'; data: OpenClawSessionHistory }
|
||||
| {
|
||||
type: 'message'
|
||||
data: {
|
||||
sessionKey: string
|
||||
message: OpenClawSessionHistoryMessage
|
||||
messageId?: string
|
||||
messageSeq: number
|
||||
}
|
||||
}
|
||||
| { type: 'error'; data: { message: string } }
|
||||
|
||||
export class OpenClawHttpClient {
|
||||
constructor(
|
||||
private readonly hostPort: number,
|
||||
private readonly getToken: () => Promise<string>,
|
||||
) {}
|
||||
|
||||
async streamChat(
|
||||
input: OpenClawChatRequest,
|
||||
): Promise<ReadableStream<OpenClawStreamEvent>> {
|
||||
const response = await this.fetchChat(input)
|
||||
const body = response.body
|
||||
|
||||
if (!body) {
|
||||
throw new Error('OpenClaw chat response had no body')
|
||||
}
|
||||
|
||||
return createEventStream(body, input.signal)
|
||||
}
|
||||
|
||||
async getSessionHistory(
|
||||
sessionKey: string,
|
||||
input: OpenClawSessionHistoryInput = {},
|
||||
): Promise<OpenClawSessionHistory> {
|
||||
const response = await this.fetchSessionHistory(sessionKey, input, {})
|
||||
return (await response.json()) as OpenClawSessionHistory
|
||||
}
|
||||
|
||||
async streamSessionHistory(
|
||||
sessionKey: string,
|
||||
input: OpenClawSessionHistoryInput = {},
|
||||
): Promise<ReadableStream<OpenClawSessionHistoryEvent>> {
|
||||
const response = await this.fetchSessionHistory(sessionKey, input, {
|
||||
Accept: 'text/event-stream',
|
||||
})
|
||||
const body = response.body
|
||||
if (!body) {
|
||||
throw new Error('OpenClaw session history stream had no body')
|
||||
}
|
||||
return createHistoryEventStream(body, input.signal)
|
||||
}
|
||||
|
||||
async isAuthenticated(): Promise<boolean> {
|
||||
try {
|
||||
const token = await this.getToken()
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${this.hostPort}/v1/models`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
)
|
||||
return response.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchChat(input: OpenClawChatRequest): Promise<Response> {
|
||||
const token = await this.getToken()
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${this.hostPort}/v1/chat/completions`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: resolveAgentModel(input.agentId),
|
||||
stream: true,
|
||||
messages: [
|
||||
...(input.history ?? []),
|
||||
{ role: 'user', content: input.message },
|
||||
],
|
||||
user: `browseros:${input.agentId}:${input.sessionKey}`,
|
||||
}),
|
||||
signal: input.signal,
|
||||
},
|
||||
)
|
||||
|
||||
if (response.ok) {
|
||||
return response
|
||||
}
|
||||
|
||||
const detail = await response.text()
|
||||
throw new Error(
|
||||
detail || `OpenClaw chat failed with status ${response.status}`,
|
||||
)
|
||||
}
|
||||
|
||||
private async fetchSessionHistory(
|
||||
sessionKey: string,
|
||||
input: OpenClawSessionHistoryInput,
|
||||
extraHeaders: Record<string, string>,
|
||||
): Promise<Response> {
|
||||
const token = await this.getToken()
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${this.hostPort}${buildHistoryPath(sessionKey, input)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
...extraHeaders,
|
||||
},
|
||||
signal: input.signal,
|
||||
},
|
||||
)
|
||||
|
||||
if (response.status === 404) {
|
||||
throw new OpenClawSessionNotFoundError(sessionKey)
|
||||
}
|
||||
if (!response.ok) {
|
||||
const detail = await response.text()
|
||||
throw new Error(
|
||||
detail ||
|
||||
`OpenClaw session history failed with status ${response.status}`,
|
||||
)
|
||||
}
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
function buildHistoryPath(
|
||||
sessionKey: string,
|
||||
input: OpenClawSessionHistoryInput,
|
||||
): string {
|
||||
const qs = new URLSearchParams()
|
||||
if (input.limit !== undefined) qs.set('limit', String(input.limit))
|
||||
if (input.cursor !== undefined) qs.set('cursor', input.cursor)
|
||||
const suffix = qs.toString()
|
||||
return `/sessions/${encodeURIComponent(sessionKey)}/history${
|
||||
suffix ? `?${suffix}` : ''
|
||||
}`
|
||||
}
|
||||
|
||||
function resolveAgentModel(agentId: string): string {
|
||||
return agentId === 'main' ? 'openclaw' : `openclaw/${agentId}`
|
||||
}
|
||||
|
||||
function createEventStream(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
signal?: AbortSignal,
|
||||
): ReadableStream<OpenClawStreamEvent> {
|
||||
return new ReadableStream<OpenClawStreamEvent>({
|
||||
start(controller) {
|
||||
void pumpChatEvents(body, controller, signal)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function pumpChatEvents(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
controller: ReadableStreamDefaultController<OpenClawStreamEvent>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const reader = body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let text = ''
|
||||
let done = false
|
||||
const parser = createParser({
|
||||
onEvent(message) {
|
||||
if (done) return
|
||||
const nextText = updateAccumulatedText(message, text)
|
||||
done = handleMessage(message, controller, nextText, done)
|
||||
if (!done) {
|
||||
text = nextText
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
if (signal?.aborted) {
|
||||
await reader.cancel()
|
||||
done = true
|
||||
controller.close()
|
||||
return
|
||||
}
|
||||
|
||||
const { done: streamDone, value } = await reader.read()
|
||||
if (streamDone) break
|
||||
parser.feed(decoder.decode(value, { stream: true }))
|
||||
}
|
||||
} catch (error) {
|
||||
if (!done) {
|
||||
controller.enqueue({
|
||||
type: 'error',
|
||||
data: {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
})
|
||||
done = true
|
||||
controller.close()
|
||||
}
|
||||
} finally {
|
||||
if (!done) {
|
||||
controller.close()
|
||||
}
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
|
||||
function handleMessage(
|
||||
message: EventSourceMessage,
|
||||
controller: ReadableStreamDefaultController<OpenClawStreamEvent>,
|
||||
text: string,
|
||||
done: boolean,
|
||||
): boolean {
|
||||
if (message.data === '[DONE]') {
|
||||
return finishStream(controller, text, done)
|
||||
}
|
||||
|
||||
const chunk = parseChunk(message.data)
|
||||
if (!chunk) {
|
||||
controller.enqueue({
|
||||
type: 'error',
|
||||
data: { message: 'Failed to parse OpenClaw chat stream chunk' },
|
||||
})
|
||||
controller.close()
|
||||
return true
|
||||
}
|
||||
|
||||
for (const event of mapChunkToEvents(chunk)) {
|
||||
controller.enqueue(event)
|
||||
}
|
||||
|
||||
return hasFinishReason(chunk) ? finishStream(controller, text, done) : false
|
||||
}
|
||||
|
||||
function updateAccumulatedText(
|
||||
message: EventSourceMessage,
|
||||
text: string,
|
||||
): string {
|
||||
const chunk = parseChunk(message.data)
|
||||
if (!chunk) return text
|
||||
|
||||
let next = text
|
||||
for (const choice of readChoices(chunk)) {
|
||||
const delta = readDeltaText(choice)
|
||||
if (delta) {
|
||||
next += delta
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function finishStream(
|
||||
controller: ReadableStreamDefaultController<OpenClawStreamEvent>,
|
||||
text: string,
|
||||
done: boolean,
|
||||
): boolean {
|
||||
if (!done) {
|
||||
if (!text.trim()) {
|
||||
controller.enqueue({
|
||||
type: 'error',
|
||||
data: {
|
||||
message: "Agent couldn't generate a response. Please try again.",
|
||||
},
|
||||
})
|
||||
controller.close()
|
||||
return true
|
||||
}
|
||||
controller.enqueue({
|
||||
type: 'done',
|
||||
data: { text },
|
||||
})
|
||||
controller.close()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function mapChunkToEvents(
|
||||
chunk: Record<string, unknown>,
|
||||
): OpenClawStreamEvent[] {
|
||||
const events: OpenClawStreamEvent[] = []
|
||||
|
||||
for (const choice of readChoices(chunk)) {
|
||||
const delta = readDeltaText(choice)
|
||||
if (delta) {
|
||||
events.push({
|
||||
type: 'text-delta',
|
||||
data: { text: delta },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
function hasFinishReason(chunk: Record<string, unknown>): boolean {
|
||||
return readChoices(chunk).some((choice) => !!readFinishReason(choice))
|
||||
}
|
||||
|
||||
function readChoices(
|
||||
chunk: Record<string, unknown>,
|
||||
): Array<Record<string, unknown>> {
|
||||
const choices = chunk.choices
|
||||
return Array.isArray(choices)
|
||||
? choices.filter(
|
||||
(choice): choice is Record<string, unknown> =>
|
||||
!!choice && typeof choice === 'object',
|
||||
)
|
||||
: []
|
||||
}
|
||||
|
||||
function readDeltaText(choice: Record<string, unknown>): string {
|
||||
const delta = choice.delta
|
||||
if (!delta || typeof delta !== 'object') return ''
|
||||
|
||||
const content = (delta as Record<string, unknown>).content
|
||||
return typeof content === 'string' ? content : ''
|
||||
}
|
||||
|
||||
function readFinishReason(choice: Record<string, unknown>): string | null {
|
||||
const reason = choice.finish_reason
|
||||
return typeof reason === 'string' && reason ? reason : null
|
||||
}
|
||||
|
||||
function parseChunk(data: string): Record<string, unknown> | null {
|
||||
try {
|
||||
return JSON.parse(data) as Record<string, unknown>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function createHistoryEventStream(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
signal?: AbortSignal,
|
||||
): ReadableStream<OpenClawSessionHistoryEvent> {
|
||||
return new ReadableStream<OpenClawSessionHistoryEvent>({
|
||||
start(controller) {
|
||||
void pumpHistoryEvents(body, controller, signal)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function pumpHistoryEvents(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
controller: ReadableStreamDefaultController<OpenClawSessionHistoryEvent>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const reader = body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let closed = false
|
||||
const close = () => {
|
||||
if (closed) return
|
||||
closed = true
|
||||
controller.close()
|
||||
}
|
||||
const parser = createParser({
|
||||
onEvent(message) {
|
||||
if (closed) return
|
||||
const event = toHistoryEvent(message)
|
||||
if (!event) return
|
||||
controller.enqueue(event)
|
||||
if (event.type === 'error') close()
|
||||
},
|
||||
})
|
||||
|
||||
const onAbort = () => {
|
||||
void reader.cancel().catch(() => {})
|
||||
close()
|
||||
}
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
if (signal?.aborted) {
|
||||
await reader.cancel()
|
||||
close()
|
||||
return
|
||||
}
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
parser.feed(decoder.decode(value, { stream: true }))
|
||||
}
|
||||
} catch (error) {
|
||||
if (!closed) {
|
||||
controller.enqueue({
|
||||
type: 'error',
|
||||
data: {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
})
|
||||
close()
|
||||
}
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
close()
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
|
||||
function toHistoryEvent(
|
||||
message: EventSourceMessage,
|
||||
): OpenClawSessionHistoryEvent | null {
|
||||
if (!message.event) return null
|
||||
const payload = parseChunk(message.data)
|
||||
if (!payload) return null
|
||||
if (message.event === 'history') {
|
||||
return {
|
||||
type: 'history',
|
||||
data: payload as unknown as OpenClawSessionHistory,
|
||||
}
|
||||
}
|
||||
if (message.event === 'message') {
|
||||
return {
|
||||
type: 'message',
|
||||
data: payload as unknown as {
|
||||
sessionKey: string
|
||||
message: OpenClawSessionHistoryMessage
|
||||
messageId?: string
|
||||
messageSeq: number
|
||||
},
|
||||
}
|
||||
}
|
||||
if (message.event === 'error') {
|
||||
const errMessage =
|
||||
typeof payload.message === 'string'
|
||||
? payload.message
|
||||
: 'OpenClaw session history stream error'
|
||||
return { type: 'error', data: { message: errMessage } }
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -1,441 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types for raw JSONL line parsing (matches OpenClaw's internal format)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PiContentBlock {
|
||||
type: string
|
||||
text?: string
|
||||
id?: string
|
||||
name?: string
|
||||
arguments?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface PiMessage {
|
||||
role?: 'user' | 'assistant' | 'toolResult'
|
||||
content?: PiContentBlock[]
|
||||
stopReason?: string
|
||||
errorMessage?: string
|
||||
usage?: {
|
||||
input?: number
|
||||
output?: number
|
||||
cost?: {
|
||||
total?: number
|
||||
}
|
||||
}
|
||||
model?: string
|
||||
provider?: string
|
||||
toolCallId?: string
|
||||
toolName?: string
|
||||
isError?: boolean
|
||||
}
|
||||
|
||||
interface PiLine {
|
||||
type: string
|
||||
id?: string
|
||||
timestamp?: string
|
||||
message?: PiMessage
|
||||
provider?: string
|
||||
modelId?: string
|
||||
thinkingLevel?: string
|
||||
summary?: string
|
||||
firstKeptEntryId?: string
|
||||
tokensBefore?: number
|
||||
}
|
||||
|
||||
interface SessionsJsonEntry {
|
||||
sessionId?: string
|
||||
updatedAt?: number
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
type SessionsJson = Record<string, SessionsJsonEntry>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ClawEventType =
|
||||
| 'user.message'
|
||||
| 'agent.message'
|
||||
| 'agent.thinking'
|
||||
| 'agent.tool_use'
|
||||
| 'agent.tool_result'
|
||||
| 'session.model_change'
|
||||
| 'session.thinking_level_change'
|
||||
| 'session.compaction'
|
||||
|
||||
export interface ClawEvent {
|
||||
eventId: string
|
||||
type: ClawEventType
|
||||
content: string
|
||||
createdAt: number
|
||||
tokensIn?: number
|
||||
tokensOut?: number
|
||||
costUsd?: number
|
||||
model?: string
|
||||
toolName?: string
|
||||
toolCallId?: string
|
||||
toolArguments?: Record<string, unknown>
|
||||
isError?: boolean
|
||||
}
|
||||
|
||||
export interface JsonlSessionEntry {
|
||||
key: string
|
||||
sessionId: string
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface JsonlSessionStats {
|
||||
userTurns: number
|
||||
assistantMessages: number
|
||||
toolCalls: number
|
||||
totalCostUsd: number
|
||||
totalTokensIn: number
|
||||
totalTokensOut: number
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reader
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Reads OpenClaw's per-session JSONL files directly from the host filesystem.
|
||||
* OpenClaw is the sole writer — this reader never modifies the files.
|
||||
*
|
||||
* Path layout on the host (via Lima virtiofs mount):
|
||||
* <stateRoot>/agents/<agentId>/sessions/sessions.json
|
||||
* <stateRoot>/agents/<agentId>/sessions/<piSessionId>.jsonl
|
||||
*/
|
||||
export class OpenClawJsonlReader {
|
||||
constructor(private readonly stateRoot: string) {}
|
||||
|
||||
/** List all sessions for an agent by reading sessions.json. */
|
||||
listSessions(agentId: string): JsonlSessionEntry[] {
|
||||
const sessionsJson = this.readSessionsJson(agentId)
|
||||
if (!sessionsJson) return []
|
||||
|
||||
const entries: JsonlSessionEntry[] = []
|
||||
for (const [key, entry] of Object.entries(sessionsJson)) {
|
||||
if (typeof entry.sessionId === 'string') {
|
||||
entries.push({
|
||||
key,
|
||||
sessionId: entry.sessionId,
|
||||
updatedAt: typeof entry.updatedAt === 'number' ? entry.updatedAt : 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
return entries.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
}
|
||||
|
||||
/** List all agent IDs by scanning the agents directory. */
|
||||
listAgents(): string[] {
|
||||
try {
|
||||
const entries = readdirSync(this.safePath('agents'), {
|
||||
withFileTypes: true,
|
||||
})
|
||||
return entries.filter((e) => e.isDirectory()).map((e) => e.name)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** Read and parse all events from a session's JSONL file. */
|
||||
listBySession(agentId: string, sessionKey: string): ClawEvent[] {
|
||||
const piSessionId = this.resolvePiSessionId(agentId, sessionKey)
|
||||
if (!piSessionId) return []
|
||||
|
||||
const filePath = this.jsonlPath(agentId, piSessionId)
|
||||
let raw: string
|
||||
try {
|
||||
raw = readFileSync(filePath, 'utf8')
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
const events: ClawEvent[] = []
|
||||
for (const line of raw.split('\n')) {
|
||||
if (!line.trim()) continue
|
||||
let parsed: PiLine
|
||||
try {
|
||||
parsed = JSON.parse(line) as PiLine
|
||||
} catch {
|
||||
// Skip malformed lines — a partial line at the tail is possible
|
||||
// if OpenClaw is mid-write.
|
||||
continue
|
||||
}
|
||||
for (const event of mapLineToEvents(parsed)) {
|
||||
events.push(event)
|
||||
}
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
/** Get the latest assistant message from a session. */
|
||||
latestAgentMessage(
|
||||
agentId: string,
|
||||
sessionKey: string,
|
||||
): ClawEvent | undefined {
|
||||
const events = this.listBySession(agentId, sessionKey)
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
if (events[i]?.type === 'agent.message') return events[i]
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Count user turns in a session. */
|
||||
countUserTurns(agentId: string, sessionKey: string): number {
|
||||
const events = this.listBySession(agentId, sessionKey)
|
||||
let n = 0
|
||||
for (const e of events) {
|
||||
if (e.type === 'user.message') n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
/** Aggregate stats for a session. */
|
||||
getSessionStats(agentId: string, sessionKey: string): JsonlSessionStats {
|
||||
const events = this.listBySession(agentId, sessionKey)
|
||||
const stats: JsonlSessionStats = {
|
||||
userTurns: 0,
|
||||
assistantMessages: 0,
|
||||
toolCalls: 0,
|
||||
totalCostUsd: 0,
|
||||
totalTokensIn: 0,
|
||||
totalTokensOut: 0,
|
||||
}
|
||||
for (const e of events) {
|
||||
if (e.type === 'user.message') stats.userTurns++
|
||||
if (e.type === 'agent.message') {
|
||||
stats.assistantMessages++
|
||||
if (e.costUsd) stats.totalCostUsd += e.costUsd
|
||||
if (e.tokensIn) stats.totalTokensIn += e.tokensIn
|
||||
if (e.tokensOut) stats.totalTokensOut += e.tokensOut
|
||||
}
|
||||
if (e.type === 'agent.tool_use') stats.toolCalls++
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
// ── Private helpers ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ensure a resolved path stays within stateRoot to prevent path traversal
|
||||
* via crafted agentId or sessionId values containing ".." segments.
|
||||
*/
|
||||
private safePath(...segments: string[]): string {
|
||||
const resolved = resolve(this.stateRoot, ...segments)
|
||||
const root = resolve(this.stateRoot)
|
||||
if (!resolved.startsWith(`${root}/`) && resolved !== root) {
|
||||
throw new Error(`Path traversal blocked: ${segments.join('/')}`)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
private readSessionsJson(agentId: string): SessionsJson | null {
|
||||
const filePath = this.safePath(
|
||||
'agents',
|
||||
agentId,
|
||||
'sessions',
|
||||
'sessions.json',
|
||||
)
|
||||
try {
|
||||
const raw = readFileSync(filePath, 'utf8')
|
||||
return JSON.parse(raw) as SessionsJson
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private resolvePiSessionId(
|
||||
agentId: string,
|
||||
sessionKey: string,
|
||||
): string | undefined {
|
||||
const sessionsJson = this.readSessionsJson(agentId)
|
||||
if (!sessionsJson) return undefined
|
||||
|
||||
// Try exact key match first
|
||||
const entry = sessionsJson[sessionKey]
|
||||
if (entry && typeof entry.sessionId === 'string') {
|
||||
return entry.sessionId
|
||||
}
|
||||
|
||||
// Try matching by scanning all keys (handles key format variations)
|
||||
for (const [key, value] of Object.entries(sessionsJson)) {
|
||||
if (key === sessionKey || key.endsWith(`:${sessionKey}`)) {
|
||||
if (typeof value.sessionId === 'string') return value.sessionId
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
private jsonlPath(agentId: string, piSessionId: string): string {
|
||||
return this.safePath('agents', agentId, 'sessions', `${piSessionId}.jsonl`)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSONL line → ClawEvent mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function mapLineToEvents(line: PiLine): ClawEvent[] {
|
||||
const eventId = line.id ?? ''
|
||||
const createdAt = line.timestamp ? Date.parse(line.timestamp) : Date.now()
|
||||
|
||||
if (line.type === 'model_change') {
|
||||
const model = combineModel(line.provider, line.modelId)
|
||||
if (!model) return []
|
||||
return [
|
||||
{
|
||||
eventId,
|
||||
type: 'session.model_change',
|
||||
content: model,
|
||||
createdAt,
|
||||
model,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
if (line.type === 'thinking_level_change') {
|
||||
return [
|
||||
{
|
||||
eventId,
|
||||
type: 'session.thinking_level_change',
|
||||
content: line.thinkingLevel ?? 'unknown',
|
||||
createdAt,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
if (line.type === 'compaction') {
|
||||
return [
|
||||
{
|
||||
eventId,
|
||||
type: 'session.compaction',
|
||||
content: line.summary ?? '(compacted)',
|
||||
createdAt,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
if (line.type !== 'message' || !line.message) return []
|
||||
|
||||
return mapMessageToEvents(line.message, eventId, createdAt)
|
||||
}
|
||||
|
||||
function mapMessageToEvents(
|
||||
msg: PiMessage,
|
||||
eventId: string,
|
||||
createdAt: number,
|
||||
): ClawEvent[] {
|
||||
if (msg.role === 'user') {
|
||||
const text = extractText(msg.content)
|
||||
if (!text) return []
|
||||
return [{ eventId, type: 'user.message', content: text, createdAt }]
|
||||
}
|
||||
|
||||
if (msg.role === 'assistant') {
|
||||
return mapAssistantMessage(msg, eventId, createdAt)
|
||||
}
|
||||
|
||||
if (msg.role === 'toolResult') {
|
||||
const text = extractText(msg.content)
|
||||
return [
|
||||
{
|
||||
eventId,
|
||||
type: 'agent.tool_result',
|
||||
content: text || '(no output)',
|
||||
createdAt,
|
||||
toolName: msg.toolName,
|
||||
toolCallId: msg.toolCallId,
|
||||
isError: msg.isError,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function mapAssistantMessage(
|
||||
msg: PiMessage,
|
||||
eventId: string,
|
||||
createdAt: number,
|
||||
): ClawEvent[] {
|
||||
const events: ClawEvent[] = []
|
||||
const text = extractText(msg.content)
|
||||
|
||||
if (msg.content) {
|
||||
let thinkingIdx = 0
|
||||
let toolIdx = 0
|
||||
for (const block of msg.content) {
|
||||
if (
|
||||
block.type === 'thinking' &&
|
||||
typeof block.text === 'string' &&
|
||||
block.text.length > 0
|
||||
) {
|
||||
events.push({
|
||||
eventId: `${eventId}:thinking:${thinkingIdx}`,
|
||||
type: 'agent.thinking',
|
||||
content: block.text,
|
||||
createdAt,
|
||||
})
|
||||
thinkingIdx++
|
||||
}
|
||||
if (block.type === 'toolCall' && block.name) {
|
||||
events.push({
|
||||
eventId: `${eventId}:tool:${block.id ?? toolIdx}`,
|
||||
type: 'agent.tool_use',
|
||||
content: block.name,
|
||||
createdAt,
|
||||
toolName: block.name,
|
||||
toolCallId: block.id,
|
||||
toolArguments: block.arguments,
|
||||
})
|
||||
toolIdx++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (text) {
|
||||
events.push({
|
||||
eventId,
|
||||
type: 'agent.message',
|
||||
content: text,
|
||||
createdAt,
|
||||
tokensIn: msg.usage?.input,
|
||||
tokensOut: msg.usage?.output,
|
||||
costUsd: msg.usage?.cost?.total,
|
||||
model: combineModel(msg.provider, msg.model),
|
||||
})
|
||||
}
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
function extractText(blocks: PiContentBlock[] | undefined): string {
|
||||
if (!blocks || blocks.length === 0) return ''
|
||||
const parts: string[] = []
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'text' && typeof block.text === 'string') {
|
||||
parts.push(block.text)
|
||||
}
|
||||
}
|
||||
return parts.join('')
|
||||
}
|
||||
|
||||
function combineModel(
|
||||
provider: string | undefined,
|
||||
model: string | undefined,
|
||||
): string | undefined {
|
||||
if (!model) return undefined
|
||||
return provider ? `${provider}/${model}` : model
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export const SUPPORTED_OPENCLAW_PROVIDERS = [
|
||||
'openrouter',
|
||||
'openai',
|
||||
'anthropic',
|
||||
'moonshot',
|
||||
] as const
|
||||
|
||||
export type SupportedOpenClawProvider =
|
||||
(typeof SUPPORTED_OPENCLAW_PROVIDERS)[number]
|
||||
|
||||
export interface CustomOpenClawProviderConfig {
|
||||
providerId: string
|
||||
apiKeyEnvVar: string
|
||||
config: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface ResolvedOpenClawProviderConfig {
|
||||
envValues: Record<string, string>
|
||||
model?: string
|
||||
providerType?: SupportedOpenClawProvider
|
||||
customProvider?: CustomOpenClawProviderConfig
|
||||
}
|
||||
|
||||
const PROVIDER_ENV_VARS: Record<SupportedOpenClawProvider, string> = {
|
||||
anthropic: 'ANTHROPIC_API_KEY',
|
||||
moonshot: 'MOONSHOT_API_KEY',
|
||||
openai: 'OPENAI_API_KEY',
|
||||
openrouter: 'OPENROUTER_API_KEY',
|
||||
}
|
||||
|
||||
export class UnsupportedOpenClawProviderError extends Error {
|
||||
constructor(providerType: string) {
|
||||
super(`Unsupported OpenClaw provider: ${providerType}`)
|
||||
this.name = 'UnsupportedOpenClawProviderError'
|
||||
}
|
||||
}
|
||||
|
||||
export function isUnsupportedOpenClawProviderError(
|
||||
error: unknown,
|
||||
): error is UnsupportedOpenClawProviderError {
|
||||
return (
|
||||
error instanceof UnsupportedOpenClawProviderError ||
|
||||
(error instanceof Error &&
|
||||
error.name === 'UnsupportedOpenClawProviderError')
|
||||
)
|
||||
}
|
||||
|
||||
export function isSupportedOpenClawProvider(
|
||||
providerType: string,
|
||||
): providerType is SupportedOpenClawProvider {
|
||||
return SUPPORTED_OPENCLAW_PROVIDERS.includes(
|
||||
providerType as SupportedOpenClawProvider,
|
||||
)
|
||||
}
|
||||
|
||||
export function assertSupportedOpenClawProvider(
|
||||
providerType?: string,
|
||||
): SupportedOpenClawProvider | undefined {
|
||||
if (!providerType) {
|
||||
return undefined
|
||||
}
|
||||
if (!isSupportedOpenClawProvider(providerType)) {
|
||||
throw new UnsupportedOpenClawProviderError(providerType)
|
||||
}
|
||||
return providerType
|
||||
}
|
||||
|
||||
export function buildOpenClawModelRef(
|
||||
providerType: SupportedOpenClawProvider,
|
||||
modelId?: string,
|
||||
): string | undefined {
|
||||
return modelId ? `${providerType}/${modelId}` : undefined
|
||||
}
|
||||
|
||||
export function deriveOpenClawProviderId(input: {
|
||||
providerType?: string
|
||||
providerName?: string
|
||||
baseUrl?: string
|
||||
}): string {
|
||||
const source =
|
||||
input.providerName?.trim() ||
|
||||
input.baseUrl?.trim() ||
|
||||
input.providerType?.trim() ||
|
||||
'custom-provider'
|
||||
|
||||
const candidate = source
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
|
||||
return candidate || 'custom-provider'
|
||||
}
|
||||
|
||||
export function deriveOpenClawApiKeyEnvVar(providerId: string): string {
|
||||
return `${providerId.toUpperCase().replace(/-/g, '_')}_API_KEY`
|
||||
}
|
||||
|
||||
export function getOpenClawProviderEnvVar(
|
||||
providerType: SupportedOpenClawProvider,
|
||||
): string {
|
||||
return PROVIDER_ENV_VARS[providerType]
|
||||
}
|
||||
|
||||
export function resolveSupportedOpenClawProvider(input: {
|
||||
providerType?: string
|
||||
providerName?: string
|
||||
baseUrl?: string
|
||||
apiKey?: string
|
||||
modelId?: string
|
||||
}): ResolvedOpenClawProviderConfig {
|
||||
if (!input.providerType) {
|
||||
return { envValues: {} }
|
||||
}
|
||||
|
||||
if (isSupportedOpenClawProvider(input.providerType)) {
|
||||
const providerType = input.providerType
|
||||
const envVar = getOpenClawProviderEnvVar(providerType)
|
||||
return {
|
||||
envValues: input.apiKey ? { [envVar]: input.apiKey } : {},
|
||||
model: buildOpenClawModelRef(providerType, input.modelId),
|
||||
providerType,
|
||||
}
|
||||
}
|
||||
|
||||
if (!input.baseUrl) {
|
||||
throw new UnsupportedOpenClawProviderError(input.providerType)
|
||||
}
|
||||
|
||||
const providerId = deriveOpenClawProviderId(input)
|
||||
const apiKeyEnvVar = deriveOpenClawApiKeyEnvVar(providerId)
|
||||
|
||||
return {
|
||||
envValues: input.apiKey ? { [apiKeyEnvVar]: input.apiKey } : {},
|
||||
model: input.modelId ? `${providerId}/${input.modelId}` : undefined,
|
||||
customProvider: {
|
||||
providerId,
|
||||
apiKeyEnvVar,
|
||||
config: {
|
||||
api: 'openai-completions',
|
||||
baseUrl: input.baseUrl,
|
||||
apiKey: `\${${apiKeyEnvVar}}`,
|
||||
...(input.modelId
|
||||
? {
|
||||
models: [{ id: input.modelId, name: input.modelId }],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export interface OpenClawStreamEvent {
|
||||
type:
|
||||
| 'text-delta'
|
||||
| 'thinking'
|
||||
| 'tool-start'
|
||||
| 'tool-end'
|
||||
| 'tool-output'
|
||||
| 'lifecycle'
|
||||
| 'done'
|
||||
| 'error'
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Abstraction over the Podman CLI for container lifecycle management.
|
||||
* Handles Podman machine init/start on macOS/Windows (where a Linux VM is required).
|
||||
* On Linux, machine operations are no-ops since Podman runs natively.
|
||||
*/
|
||||
|
||||
const isLinux = process.platform === 'linux'
|
||||
|
||||
export type LogFn = (msg: string) => void
|
||||
|
||||
export class PodmanRuntime {
|
||||
private podmanPath: string
|
||||
private machineReady = false
|
||||
|
||||
constructor(config?: { podmanPath?: string }) {
|
||||
this.podmanPath = config?.podmanPath ?? 'podman'
|
||||
}
|
||||
|
||||
getPodmanPath(): string {
|
||||
return this.podmanPath
|
||||
}
|
||||
|
||||
async isPodmanAvailable(): Promise<boolean> {
|
||||
try {
|
||||
const proc = Bun.spawn([this.podmanPath, '--version'], {
|
||||
stdout: 'ignore',
|
||||
stderr: 'ignore',
|
||||
})
|
||||
return (await proc.exited) === 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async getMachineStatus(): Promise<{
|
||||
initialized: boolean
|
||||
running: boolean
|
||||
}> {
|
||||
if (isLinux) return { initialized: true, running: true }
|
||||
|
||||
try {
|
||||
const proc = Bun.spawn(
|
||||
[this.podmanPath, 'machine', 'list', '--format', 'json'],
|
||||
{ stdout: 'pipe', stderr: 'ignore' },
|
||||
)
|
||||
const output = await new Response(proc.stdout).text()
|
||||
await proc.exited
|
||||
|
||||
const machines = JSON.parse(output) as Array<{
|
||||
Running?: boolean
|
||||
LastUp?: string
|
||||
}>
|
||||
|
||||
if (!machines.length) return { initialized: false, running: false }
|
||||
|
||||
const machine = machines[0]
|
||||
const running =
|
||||
machine.Running === true || machine.LastUp === 'Currently running'
|
||||
|
||||
return { initialized: true, running }
|
||||
} catch {
|
||||
return { initialized: false, running: false }
|
||||
}
|
||||
}
|
||||
|
||||
async initMachine(onLog?: LogFn): Promise<void> {
|
||||
if (isLinux) return
|
||||
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
this.podmanPath,
|
||||
'machine',
|
||||
'init',
|
||||
'--cpus',
|
||||
'2',
|
||||
'--memory',
|
||||
'2048',
|
||||
'--disk-size',
|
||||
'10',
|
||||
],
|
||||
{ stdout: 'ignore', stderr: 'pipe' },
|
||||
)
|
||||
|
||||
await this.drainStderr(proc, onLog)
|
||||
const code = await proc.exited
|
||||
if (code !== 0)
|
||||
throw new Error(`podman machine init failed with code ${code}`)
|
||||
}
|
||||
|
||||
async startMachine(onLog?: LogFn): Promise<void> {
|
||||
if (isLinux) return
|
||||
|
||||
const proc = Bun.spawn([this.podmanPath, 'machine', 'start'], {
|
||||
stdout: 'ignore',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
|
||||
await this.drainStderr(proc, onLog)
|
||||
const code = await proc.exited
|
||||
if (code !== 0)
|
||||
throw new Error(`podman machine start failed with code ${code}`)
|
||||
}
|
||||
|
||||
async stopMachine(): Promise<void> {
|
||||
if (isLinux) return
|
||||
|
||||
const proc = Bun.spawn([this.podmanPath, 'machine', 'stop'], {
|
||||
stdout: 'ignore',
|
||||
stderr: 'ignore',
|
||||
})
|
||||
const code = await proc.exited
|
||||
if (code !== 0)
|
||||
throw new Error(`podman machine stop failed with code ${code}`)
|
||||
this.machineReady = false
|
||||
}
|
||||
|
||||
async ensureReady(onLog?: LogFn): Promise<void> {
|
||||
if (this.machineReady) return
|
||||
|
||||
const status = await this.getMachineStatus()
|
||||
|
||||
if (!status.initialized) {
|
||||
onLog?.('Initializing Podman machine...')
|
||||
await this.initMachine(onLog)
|
||||
}
|
||||
|
||||
if (!status.running) {
|
||||
onLog?.('Starting Podman machine...')
|
||||
await this.startMachine(onLog)
|
||||
}
|
||||
|
||||
this.machineReady = true
|
||||
}
|
||||
|
||||
async runCommand(
|
||||
args: string[],
|
||||
options?: {
|
||||
cwd?: string
|
||||
env?: Record<string, string>
|
||||
onOutput?: (line: string) => void
|
||||
},
|
||||
): Promise<number> {
|
||||
const useStreaming = !!options?.onOutput
|
||||
const proc = Bun.spawn([this.podmanPath, ...args], {
|
||||
cwd: options?.cwd,
|
||||
env: options?.env ? { ...process.env, ...options.env } : undefined,
|
||||
stdout: useStreaming ? 'pipe' : 'ignore',
|
||||
stderr: useStreaming ? 'pipe' : 'ignore',
|
||||
})
|
||||
|
||||
if (options?.onOutput) {
|
||||
await Promise.all([
|
||||
this.drainStream(proc.stdout ?? null, options.onOutput),
|
||||
this.drainStream(proc.stderr ?? null, options.onOutput),
|
||||
])
|
||||
}
|
||||
|
||||
return proc.exited
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow container logs. Returns a stop function that terminates the
|
||||
* underlying `podman logs -f` process. Each output line is passed to
|
||||
* onLine as-is.
|
||||
*/
|
||||
tailContainerLogs(containerName: string, onLine: LogFn): () => void {
|
||||
const proc = Bun.spawn(
|
||||
[this.podmanPath, 'logs', '-f', '--tail', '0', containerName],
|
||||
{ stdout: 'pipe', stderr: 'pipe' },
|
||||
)
|
||||
|
||||
void this.drainStream(proc.stdout ?? null, onLine)
|
||||
void this.drainStream(proc.stderr ?? null, onLine)
|
||||
|
||||
let stopped = false
|
||||
return () => {
|
||||
if (stopped) return
|
||||
stopped = true
|
||||
try {
|
||||
proc.kill()
|
||||
} catch {
|
||||
// process may already be gone
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists running container names. Used to check whether non-BrowserOS
|
||||
* containers are running before stopping the Podman machine.
|
||||
*/
|
||||
async listRunningContainers(): Promise<string[]> {
|
||||
const proc = Bun.spawn([this.podmanPath, 'ps', '--format', '{{.Names}}'], {
|
||||
stdout: 'pipe',
|
||||
stderr: 'ignore',
|
||||
})
|
||||
const output = await new Response(proc.stdout).text()
|
||||
await proc.exited
|
||||
|
||||
return output
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((name) => name.trim())
|
||||
}
|
||||
|
||||
private async drainStderr(
|
||||
proc: {
|
||||
stderr: ReadableStream<Uint8Array> | null
|
||||
exited: Promise<number>
|
||||
},
|
||||
onLog?: LogFn,
|
||||
): Promise<void> {
|
||||
if (!onLog || !proc.stderr) return
|
||||
await this.drainStream(proc.stderr, onLog)
|
||||
}
|
||||
|
||||
private async drainStream(
|
||||
stream: ReadableStream<Uint8Array> | null,
|
||||
onLine: (line: string) => void,
|
||||
): Promise<void> {
|
||||
if (!stream) return
|
||||
const reader = stream.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() ?? ''
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed) onLine(trimmed)
|
||||
}
|
||||
}
|
||||
if (buffer.trim()) onLine(buffer.trim())
|
||||
}
|
||||
}
|
||||
|
||||
let runtime: PodmanRuntime | null = null
|
||||
|
||||
export function getPodmanRuntime(): PodmanRuntime {
|
||||
if (!runtime) runtime = new PodmanRuntime()
|
||||
return runtime
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import {
|
||||
type BROWSEROS_ROLE_TEMPLATES,
|
||||
getBrowserOSRoleTemplate,
|
||||
} from '@browseros/shared/constants/role-aware-agents'
|
||||
import type {
|
||||
BrowserOSAgentRoleId,
|
||||
BrowserOSAgentRoleSummary,
|
||||
BrowserOSCustomRoleInput,
|
||||
BrowserOSRoleTemplate,
|
||||
} from '@browseros/shared/types/role-aware-agents'
|
||||
|
||||
type RoleTemplate = (typeof BROWSEROS_ROLE_TEMPLATES)[number]
|
||||
interface BootstrapRenderableRole {
|
||||
name: string
|
||||
shortDescription: string
|
||||
longDescription: string
|
||||
recommendedApps: string[]
|
||||
boundaries: BrowserOSRoleTemplate['boundaries']
|
||||
bootstrap: BrowserOSRoleTemplate['bootstrap']
|
||||
}
|
||||
|
||||
export interface RoleBootstrapFiles {
|
||||
'AGENTS.md': string
|
||||
'SOUL.md': string
|
||||
'TOOLS.md': string
|
||||
'.browseros-role.json': string
|
||||
}
|
||||
|
||||
export function resolveRoleTemplate(
|
||||
roleId: BrowserOSAgentRoleId,
|
||||
): RoleTemplate {
|
||||
const role = getBrowserOSRoleTemplate(roleId)
|
||||
if (!role) {
|
||||
throw new Error(`Unknown BrowserOS role: ${roleId}`)
|
||||
}
|
||||
return role
|
||||
}
|
||||
|
||||
export function buildRoleBootstrapFiles(input: {
|
||||
role: BrowserOSRoleTemplate | BrowserOSCustomRoleInput
|
||||
agentName: string
|
||||
}): RoleBootstrapFiles {
|
||||
const normalizedRole = normalizeRoleForBootstrap(input.role)
|
||||
const roleId = 'id' in input.role ? input.role.id : undefined
|
||||
return {
|
||||
'AGENTS.md': normalizedRole.bootstrap.agentsMd,
|
||||
'SOUL.md': normalizedRole.bootstrap.soulMd,
|
||||
'TOOLS.md': normalizedRole.bootstrap.toolsMd,
|
||||
'.browseros-role.json': `${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
roleSource: roleId ? 'builtin' : 'custom',
|
||||
roleId,
|
||||
roleName: normalizedRole.name,
|
||||
shortDescription: normalizedRole.shortDescription,
|
||||
createdBy: 'browseros',
|
||||
agentName: input.agentName,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
}
|
||||
}
|
||||
|
||||
export function toRoleSummary(
|
||||
role: BrowserOSRoleTemplate | BrowserOSCustomRoleInput,
|
||||
): BrowserOSAgentRoleSummary {
|
||||
const normalizedRole = normalizeRoleForBootstrap(role)
|
||||
return {
|
||||
roleSource: 'id' in role ? 'builtin' : 'custom',
|
||||
roleId: 'id' in role ? role.id : undefined,
|
||||
roleName: normalizedRole.name,
|
||||
shortDescription: normalizedRole.shortDescription,
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeCustomRole(
|
||||
role: BrowserOSCustomRoleInput,
|
||||
): BootstrapRenderableRole {
|
||||
const recommendedApps = Array.isArray(role.recommendedApps)
|
||||
? role.recommendedApps.filter(
|
||||
(app): app is string => typeof app === 'string',
|
||||
)
|
||||
: []
|
||||
const boundaries = Array.isArray(role.boundaries) ? role.boundaries : []
|
||||
|
||||
return {
|
||||
name: role.name,
|
||||
shortDescription: role.shortDescription,
|
||||
longDescription: role.longDescription,
|
||||
recommendedApps,
|
||||
boundaries,
|
||||
bootstrap: {
|
||||
agentsMd:
|
||||
role.bootstrap?.agentsMd?.trim() ||
|
||||
buildAgentsMd({
|
||||
name: role.name,
|
||||
longDescription: role.longDescription,
|
||||
boundaries,
|
||||
}),
|
||||
soulMd:
|
||||
role.bootstrap?.soulMd?.trim() ||
|
||||
buildSoulMd({
|
||||
name: role.name,
|
||||
shortDescription: role.shortDescription,
|
||||
longDescription: role.longDescription,
|
||||
}),
|
||||
toolsMd:
|
||||
role.bootstrap?.toolsMd?.trim() ||
|
||||
buildToolsMd({
|
||||
boundaries,
|
||||
recommendedApps,
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRoleForBootstrap(
|
||||
role: BrowserOSRoleTemplate | BrowserOSCustomRoleInput,
|
||||
): BootstrapRenderableRole {
|
||||
return 'id' in role ? role : normalizeCustomRole(role)
|
||||
}
|
||||
|
||||
function buildAgentsMd(input: {
|
||||
name: string
|
||||
longDescription: string
|
||||
boundaries: BrowserOSRoleTemplate['boundaries']
|
||||
}): string {
|
||||
const boundaryLines = input.boundaries
|
||||
.map(
|
||||
(boundary) =>
|
||||
`- ${boundary.label}: ${boundary.description} Default mode: ${boundary.defaultMode}.`,
|
||||
)
|
||||
.join('\n')
|
||||
|
||||
return `# ${input.name}
|
||||
|
||||
You are the ${input.name} specialist for this workspace.
|
||||
|
||||
## Core Purpose
|
||||
${input.longDescription}
|
||||
|
||||
## Operating Rules
|
||||
${boundaryLines}
|
||||
|
||||
## Default Output Style
|
||||
- concise
|
||||
- action-oriented
|
||||
- explicit about blockers and approvals
|
||||
`
|
||||
}
|
||||
|
||||
function buildSoulMd(input: {
|
||||
name: string
|
||||
shortDescription: string
|
||||
longDescription: string
|
||||
}): string {
|
||||
return `# Operating Style
|
||||
|
||||
You act like a trusted ${input.name}.
|
||||
|
||||
## Working Posture
|
||||
- calm
|
||||
- structured
|
||||
- direct
|
||||
- explicit about tradeoffs
|
||||
|
||||
## Role Framing
|
||||
${input.shortDescription}
|
||||
|
||||
${input.longDescription}
|
||||
`
|
||||
}
|
||||
|
||||
function buildToolsMd(input: {
|
||||
boundaries: BrowserOSRoleTemplate['boundaries']
|
||||
recommendedApps: string[]
|
||||
}): string {
|
||||
const boundaryLines = input.boundaries
|
||||
.map((boundary) => `- ${boundary.label}: ${boundary.defaultMode}`)
|
||||
.join('\n')
|
||||
|
||||
const appsLine =
|
||||
input.recommendedApps.length > 0
|
||||
? input.recommendedApps.join(', ')
|
||||
: 'No specific apps configured yet.'
|
||||
|
||||
return `# Tooling Guidelines
|
||||
|
||||
- Use BrowserOS MCP for browser and connected SaaS tasks.
|
||||
- Prefer read, summarize, and draft flows.
|
||||
- Keep outputs in the workspace when possible so work remains inspectable.
|
||||
|
||||
## Recommended Apps
|
||||
${appsLine}
|
||||
|
||||
## Boundary Defaults
|
||||
${boundaryLines}
|
||||
`
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*
|
||||
* Runtime state for the OpenClaw gateway. Today this is just the host port
|
||||
* we mapped the gateway container to, persisted so that a once-chosen port
|
||||
* is reused across restarts when it's still free.
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { createServer } from 'node:net'
|
||||
import { join } from 'node:path'
|
||||
import { OPENCLAW_GATEWAY_CONTAINER_PORT } from '@browseros/shared/constants/openclaw'
|
||||
import { getOpenClawStateDir } from './openclaw-env'
|
||||
|
||||
const RUNTIME_STATE_FILE = 'runtime-state.json'
|
||||
|
||||
interface RuntimeState {
|
||||
gatewayPort: number
|
||||
}
|
||||
|
||||
function readForcedGatewayPort(): number | null {
|
||||
const raw = process.env.BROWSEROS_TEST_OPENCLAW_GATEWAY_PORT?.trim()
|
||||
if (!raw) return null
|
||||
|
||||
const parsed = Number.parseInt(raw, 10)
|
||||
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
|
||||
return null
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function getRuntimeStatePath(openclawDir: string): string {
|
||||
return join(getOpenClawStateDir(openclawDir), RUNTIME_STATE_FILE)
|
||||
}
|
||||
|
||||
export async function readPersistedGatewayPort(
|
||||
openclawDir: string,
|
||||
): Promise<number | null> {
|
||||
const path = getRuntimeStatePath(openclawDir)
|
||||
if (!existsSync(path)) return null
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
await readFile(path, 'utf-8'),
|
||||
) as Partial<RuntimeState>
|
||||
if (
|
||||
typeof parsed.gatewayPort === 'number' &&
|
||||
Number.isInteger(parsed.gatewayPort) &&
|
||||
parsed.gatewayPort > 0 &&
|
||||
parsed.gatewayPort <= 65535
|
||||
) {
|
||||
return parsed.gatewayPort
|
||||
}
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function writePersistedGatewayPort(
|
||||
openclawDir: string,
|
||||
port: number,
|
||||
): Promise<void> {
|
||||
await mkdir(getOpenClawStateDir(openclawDir), { recursive: true })
|
||||
const state: RuntimeState = { gatewayPort: port }
|
||||
await writeFile(
|
||||
getRuntimeStatePath(openclawDir),
|
||||
`${JSON.stringify(state, null, 2)}\n`,
|
||||
)
|
||||
}
|
||||
|
||||
function isPortAvailable(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const server = createServer()
|
||||
server.once('error', () => resolve(false))
|
||||
server.once('listening', () => {
|
||||
server.close(() => resolve(true))
|
||||
})
|
||||
server.listen(port, '127.0.0.1')
|
||||
})
|
||||
}
|
||||
|
||||
async function findAvailablePort(startPort: number): Promise<number> {
|
||||
let port = startPort
|
||||
while (!(await isPortAvailable(port))) {
|
||||
port++
|
||||
}
|
||||
return port
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a host port for the gateway container and persist it. Prefers the
|
||||
* previously persisted port when it's still bindable; otherwise scans
|
||||
* upward from OPENCLAW_GATEWAY_CONTAINER_PORT until a free port is found.
|
||||
*/
|
||||
export async function allocateGatewayPort(
|
||||
openclawDir: string,
|
||||
): Promise<number> {
|
||||
const forcedPort = readForcedGatewayPort()
|
||||
if (forcedPort !== null) {
|
||||
await writePersistedGatewayPort(openclawDir, forcedPort)
|
||||
return forcedPort
|
||||
}
|
||||
|
||||
const persisted = await readPersistedGatewayPort(openclawDir)
|
||||
if (persisted !== null && (await isPortAvailable(persisted))) {
|
||||
return persisted
|
||||
}
|
||||
const port = await findAvailablePort(OPENCLAW_GATEWAY_CONTAINER_PORT)
|
||||
await writePersistedGatewayPort(openclawDir, port)
|
||||
return port
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
OPENCLAW_CONTAINER_HOME,
|
||||
OPENCLAW_TERMINAL_SHELL,
|
||||
} from '@browseros/shared/constants/openclaw'
|
||||
import { buildNerdctlCommand } from '../../../lib/container'
|
||||
import { logger } from '../../../lib/logger'
|
||||
|
||||
export const TERMINAL_HOME_DIR = OPENCLAW_CONTAINER_HOME
|
||||
@@ -12,9 +11,7 @@ const TERMINAL_NAME = 'xterm-256color'
|
||||
|
||||
interface TerminalSessionDeps {
|
||||
containerName: string
|
||||
limaHome: string
|
||||
limactlPath: string
|
||||
vmName: string
|
||||
podmanPath: string
|
||||
workingDir: string
|
||||
onExit: (exitCode: number) => void
|
||||
onOutput: (data: string) => void
|
||||
@@ -27,44 +24,32 @@ export interface TerminalSession {
|
||||
}
|
||||
|
||||
export function buildTerminalExecCommand(
|
||||
limactlPath: string,
|
||||
vmName: string,
|
||||
podmanPath: string,
|
||||
containerName: string,
|
||||
workingDir: string,
|
||||
): string[] {
|
||||
return [
|
||||
limactlPath,
|
||||
'shell',
|
||||
vmName,
|
||||
'--',
|
||||
...buildNerdctlCommand([
|
||||
'exec',
|
||||
'-it',
|
||||
'-w',
|
||||
workingDir,
|
||||
containerName,
|
||||
OPENCLAW_TERMINAL_SHELL,
|
||||
]),
|
||||
podmanPath,
|
||||
'exec',
|
||||
'-it',
|
||||
'-w',
|
||||
workingDir,
|
||||
containerName,
|
||||
OPENCLAW_TERMINAL_SHELL,
|
||||
]
|
||||
}
|
||||
|
||||
export function buildTerminalEnv(limaHome: string): NodeJS.ProcessEnv {
|
||||
return { ...process.env, LIMA_HOME: limaHome, TERM: TERMINAL_NAME }
|
||||
}
|
||||
|
||||
export function createTerminalSession(
|
||||
deps: TerminalSessionDeps,
|
||||
): TerminalSession {
|
||||
const decoder = new TextDecoder()
|
||||
const proc = Bun.spawn(
|
||||
buildTerminalExecCommand(
|
||||
deps.limactlPath,
|
||||
deps.vmName,
|
||||
deps.podmanPath,
|
||||
deps.containerName,
|
||||
deps.workingDir,
|
||||
),
|
||||
{
|
||||
cwd: '/',
|
||||
terminal: {
|
||||
cols: DEFAULT_COLS,
|
||||
rows: DEFAULT_ROWS,
|
||||
@@ -73,7 +58,7 @@ export function createTerminalSession(
|
||||
if (chunk) deps.onOutput(chunk)
|
||||
},
|
||||
},
|
||||
env: buildTerminalEnv(deps.limaHome),
|
||||
env: { ...process.env, TERM: TERMINAL_NAME },
|
||||
},
|
||||
)
|
||||
let closed = false
|
||||
|
||||
@@ -517,45 +517,15 @@ export class Browser {
|
||||
return null
|
||||
}
|
||||
|
||||
private async resolveWindowIdForNewPage(opts?: {
|
||||
hidden?: boolean
|
||||
windowId?: number
|
||||
}): Promise<number | undefined> {
|
||||
if (!opts?.hidden) {
|
||||
return opts?.windowId
|
||||
}
|
||||
|
||||
if (opts.windowId !== undefined) {
|
||||
const windows = await this.listWindows()
|
||||
const targetWindow = windows.find(
|
||||
(window) => window.windowId === opts.windowId,
|
||||
)
|
||||
if (targetWindow && !targetWindow.isVisible) {
|
||||
return targetWindow.windowId
|
||||
}
|
||||
if (targetWindow?.isVisible) {
|
||||
logger.warn(
|
||||
'Requested hidden page target window is visible, creating a new hidden window instead',
|
||||
{
|
||||
requestedWindowId: opts.windowId,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const hiddenWindow = await this.createWindow({ hidden: true })
|
||||
return hiddenWindow.windowId
|
||||
}
|
||||
|
||||
async newPage(
|
||||
url: string,
|
||||
opts?: { hidden?: boolean; background?: boolean; windowId?: number },
|
||||
): Promise<number> {
|
||||
const windowId = await this.resolveWindowIdForNewPage(opts)
|
||||
const createResult = await this.cdp.Browser.createTab({
|
||||
url,
|
||||
...(opts?.hidden !== undefined && { hidden: opts.hidden }),
|
||||
...(opts?.background !== undefined && { background: opts.background }),
|
||||
...(windowId !== undefined && { windowId }),
|
||||
...(opts?.windowId !== undefined && { windowId: opts.windowId }),
|
||||
})
|
||||
|
||||
const tabId = (createResult.tab as TabInfo).tabId
|
||||
@@ -583,7 +553,7 @@ export class Browser {
|
||||
loadProgress: tabInfo.loadProgress,
|
||||
isPinned: tabInfo.isPinned,
|
||||
isHidden: tabInfo.isHidden,
|
||||
windowId: tabInfo.windowId ?? windowId,
|
||||
windowId: tabInfo.windowId,
|
||||
index: tabInfo.index,
|
||||
groupId: tabInfo.groupId,
|
||||
})
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { EXTERNAL_URLS } from '@browseros/shared/constants/urls'
|
||||
import { Command, InvalidArgumentError } from 'commander'
|
||||
import { z } from 'zod'
|
||||
|
||||
@@ -31,8 +30,6 @@ export const ServerConfigSchema = z.object({
|
||||
instanceBrowserosVersion: z.string().optional(),
|
||||
instanceChromiumVersion: z.string().optional(),
|
||||
aiSdkDevtoolsEnabled: z.boolean(),
|
||||
vmCachePrefetch: z.boolean(),
|
||||
vmCacheManifestUrl: z.string().url(),
|
||||
})
|
||||
|
||||
export type ServerConfig = z.infer<typeof ServerConfigSchema>
|
||||
@@ -229,11 +226,6 @@ function parseConfigFile(filePath?: string): ConfigResult<PartialConfig> {
|
||||
cfg.flags?.allow_remote_in_mcp === true ? true : undefined,
|
||||
aiSdkDevtoolsEnabled:
|
||||
cfg.flags?.ai_sdk_devtools === true ? true : undefined,
|
||||
vmCachePrefetch:
|
||||
typeof cfg.vm_cache?.prefetch === 'boolean'
|
||||
? cfg.vm_cache.prefetch
|
||||
: undefined,
|
||||
vmCacheManifestUrl: parseTrimmedString(cfg.vm_cache?.manifest_url),
|
||||
instanceClientId:
|
||||
typeof cfg.instance?.client_id === 'string'
|
||||
? cfg.instance.client_id
|
||||
@@ -280,10 +272,6 @@ function parseRuntimeEnv(): PartialConfig {
|
||||
instanceClientId: process.env.BROWSEROS_CLIENT_ID,
|
||||
aiSdkDevtoolsEnabled:
|
||||
process.env.BROWSEROS_AI_SDK_DEVTOOLS === 'true' ? true : undefined,
|
||||
vmCachePrefetch: parseBooleanEnv(process.env.BROWSEROS_VM_CACHE_PREFETCH),
|
||||
vmCacheManifestUrl: parseTrimmedString(
|
||||
process.env.BROWSEROS_VM_CACHE_MANIFEST_URL,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -317,8 +305,6 @@ function getDefaults(cwd: string): PartialConfig {
|
||||
executionDir: cwd,
|
||||
mcpAllowRemote: false,
|
||||
aiSdkDevtoolsEnabled: false,
|
||||
vmCachePrefetch: true,
|
||||
vmCacheManifestUrl: EXTERNAL_URLS.VM_CACHE_MANIFEST,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,18 +325,6 @@ function safeParseInt(value: string): number | undefined {
|
||||
return Number.isNaN(num) ? undefined : num
|
||||
}
|
||||
|
||||
function parseBooleanEnv(value: string | undefined): boolean | undefined {
|
||||
if (value === 'true') return true
|
||||
if (value === 'false') return false
|
||||
return undefined
|
||||
}
|
||||
|
||||
function parseTrimmedString(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') return undefined
|
||||
const trimmed = value.trim()
|
||||
return trimmed.length > 0 ? trimmed : undefined
|
||||
}
|
||||
|
||||
function omitUndefined<T extends Record<string, unknown>>(obj: T): Partial<T> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj).filter(([_, v]) => v !== undefined),
|
||||
|
||||
@@ -19,8 +19,6 @@ export const INLINED_ENV = {
|
||||
CODEGEN_SERVICE_URL: process.env.CODEGEN_SERVICE_URL,
|
||||
POSTHOG_API_KEY: process.env.POSTHOG_API_KEY,
|
||||
BROWSEROS_CONFIG_URL: process.env.BROWSEROS_CONFIG_URL,
|
||||
BROWSEROS_VM_CACHE_PREFETCH: process.env.BROWSEROS_VM_CACHE_PREFETCH,
|
||||
BROWSEROS_VM_CACHE_MANIFEST_URL: process.env.BROWSEROS_VM_CACHE_MANIFEST_URL,
|
||||
SKILLS_CATALOG_URL: process.env.SKILLS_CATALOG_URL,
|
||||
} as const
|
||||
|
||||
@@ -29,6 +27,4 @@ export const REQUIRED_FOR_PRODUCTION = [
|
||||
'CODEGEN_SERVICE_URL',
|
||||
'POSTHOG_API_KEY',
|
||||
'BROWSEROS_CONFIG_URL',
|
||||
'BROWSEROS_VM_CACHE_PREFETCH',
|
||||
'BROWSEROS_VM_CACHE_MANIFEST_URL',
|
||||
] as const satisfies readonly (keyof typeof INLINED_ENV)[]
|
||||
|
||||
@@ -7,20 +7,7 @@ import type { ServerDiscoveryConfig } from '@browseros/shared/types/server-confi
|
||||
import { logger } from './logger'
|
||||
|
||||
export function getBrowserosDir(): string {
|
||||
const override = process.env.BROWSEROS_DIR?.trim()
|
||||
if (override) {
|
||||
return override
|
||||
}
|
||||
const dirName =
|
||||
process.env.NODE_ENV === 'development'
|
||||
? PATHS.DEV_BROWSEROS_DIR_NAME
|
||||
: PATHS.BROWSEROS_DIR_NAME
|
||||
return join(homedir(), dirName)
|
||||
}
|
||||
|
||||
export function logDevelopmentBrowserosDir(): void {
|
||||
if (process.env.NODE_ENV !== 'development') return
|
||||
logger.info(`Using development BrowserOS directory: ${getBrowserosDir()}`)
|
||||
return join(homedir(), PATHS.BROWSEROS_DIR_NAME)
|
||||
}
|
||||
|
||||
export function getMemoryDir(): string {
|
||||
@@ -48,49 +35,9 @@ export function getBuiltinSkillsDir(): string {
|
||||
}
|
||||
|
||||
export function getOpenClawDir(): string {
|
||||
return join(getVmStateDir(), PATHS.OPENCLAW_DIR_NAME)
|
||||
}
|
||||
|
||||
export function getLegacyOpenClawDir(): string {
|
||||
return join(getBrowserosDir(), PATHS.OPENCLAW_DIR_NAME)
|
||||
}
|
||||
|
||||
export function getCacheDir(): string {
|
||||
return join(getBrowserosDir(), PATHS.CACHE_DIR_NAME)
|
||||
}
|
||||
|
||||
export function getVmCacheDir(): string {
|
||||
return join(getCacheDir(), 'vm')
|
||||
}
|
||||
|
||||
export function getLimaHomeDir(): string {
|
||||
return join(getBrowserosDir(), 'lima')
|
||||
}
|
||||
|
||||
export function getVmStateDir(): string {
|
||||
return join(getBrowserosDir(), 'vm')
|
||||
}
|
||||
|
||||
export function getVmDisksDir(): string {
|
||||
return getVmCacheDir()
|
||||
}
|
||||
|
||||
export function getAgentCacheDir(): string {
|
||||
return join(getVmCacheDir(), 'images')
|
||||
}
|
||||
|
||||
export function getLazyMonitoringDir(): string {
|
||||
return join(getBrowserosDir(), 'lazy-monitoring')
|
||||
}
|
||||
|
||||
export function getLazyMonitoringRunsDir(): string {
|
||||
return join(getLazyMonitoringDir(), 'runs')
|
||||
}
|
||||
|
||||
export function getLazyMonitoringRunDir(runId: string): string {
|
||||
return join(getLazyMonitoringRunsDir(), runId)
|
||||
}
|
||||
|
||||
export function getServerConfigPath(): string {
|
||||
return join(getBrowserosDir(), PATHS.SERVER_CONFIG_FILE_NAME)
|
||||
}
|
||||
@@ -110,13 +57,10 @@ export function removeServerConfigSync(): void {
|
||||
}
|
||||
|
||||
export async function ensureBrowserosDir(): Promise<void> {
|
||||
logDevelopmentBrowserosDir()
|
||||
await mkdir(getMemoryDir(), { recursive: true })
|
||||
await mkdir(getSkillsDir(), { recursive: true })
|
||||
await mkdir(getBuiltinSkillsDir(), { recursive: true })
|
||||
await mkdir(getSessionsDir(), { recursive: true })
|
||||
await mkdir(getLazyMonitoringRunsDir(), { recursive: true })
|
||||
await mkdir(getAgentCacheDir(), { recursive: true })
|
||||
}
|
||||
|
||||
export async function cleanOldSessions(): Promise<void> {
|
||||
|
||||
@@ -11,10 +11,6 @@ import { INLINED_ENV } from '../../../env'
|
||||
import { logger } from '../../logger'
|
||||
import { fetchBrowserOSConfig, getLLMConfigFromProvider } from '../gateway'
|
||||
import { getOAuthTokenManager } from '../oauth'
|
||||
import {
|
||||
resolveMockBrowserOSConfig,
|
||||
shouldUseMockBrowserOSLLM,
|
||||
} from './mock-language-model'
|
||||
import type { ResolvedLLMConfig } from './types'
|
||||
|
||||
export async function resolveLLMConfig(
|
||||
@@ -53,9 +49,6 @@ export async function resolveLLMConfig(
|
||||
|
||||
// BrowserOS gateway: fetch config from remote service
|
||||
if (config.provider === LLM_PROVIDERS.BROWSEROS) {
|
||||
if (shouldUseMockBrowserOSLLM(config)) {
|
||||
return resolveMockBrowserOSConfig(config, browserosId)
|
||||
}
|
||||
return resolveBrowserOSConfig(config, browserosId)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import type {
|
||||
LanguageModelV3GenerateResult,
|
||||
LanguageModelV3StreamPart,
|
||||
LanguageModelV3Usage,
|
||||
} from '@ai-sdk/provider'
|
||||
import { LLM_PROVIDERS, type LLMConfig } from '@browseros/shared/schemas/llm'
|
||||
import { type LanguageModel, simulateReadableStream } from 'ai'
|
||||
import { MockLanguageModelV3 } from 'ai/test'
|
||||
import type { ResolvedLLMConfig } from './types'
|
||||
|
||||
export const MOCK_BROWSEROS_MODEL_ID = 'browseros-test-mock'
|
||||
export const MOCK_BROWSEROS_RESPONSE_TEXT = 'Mock BrowserOS test response.'
|
||||
|
||||
const MOCK_USAGE: LanguageModelV3Usage = {
|
||||
inputTokens: {
|
||||
total: 1,
|
||||
noCache: 1,
|
||||
cacheRead: undefined,
|
||||
cacheWrite: undefined,
|
||||
},
|
||||
outputTokens: {
|
||||
total: 4,
|
||||
text: 4,
|
||||
reasoning: undefined,
|
||||
},
|
||||
}
|
||||
|
||||
function createMockResult(): LanguageModelV3GenerateResult {
|
||||
return {
|
||||
content: [{ type: 'text', text: MOCK_BROWSEROS_RESPONSE_TEXT }],
|
||||
finishReason: { unified: 'stop', raw: 'stop' },
|
||||
usage: MOCK_USAGE,
|
||||
warnings: [],
|
||||
}
|
||||
}
|
||||
|
||||
export function isMockBrowserOSLLMEnabled(): boolean {
|
||||
return process.env.BROWSEROS_USE_MOCK_LLM === 'true'
|
||||
}
|
||||
|
||||
export function shouldUseMockBrowserOSLLM(
|
||||
config: Pick<LLMConfig, 'provider'>,
|
||||
): boolean {
|
||||
return (
|
||||
config.provider === LLM_PROVIDERS.BROWSEROS && isMockBrowserOSLLMEnabled()
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveMockBrowserOSConfig(
|
||||
config: LLMConfig,
|
||||
browserosId?: string,
|
||||
): ResolvedLLMConfig {
|
||||
return {
|
||||
...config,
|
||||
model: config.model ?? MOCK_BROWSEROS_MODEL_ID,
|
||||
browserosId,
|
||||
upstreamProvider: LLM_PROVIDERS.OPENAI,
|
||||
}
|
||||
}
|
||||
|
||||
export function createMockBrowserOSLanguageModel(): LanguageModel {
|
||||
const chunks: LanguageModelV3StreamPart[] = [
|
||||
{ type: 'text-start', id: 'text-1' },
|
||||
{
|
||||
type: 'text-delta',
|
||||
id: 'text-1',
|
||||
delta: MOCK_BROWSEROS_RESPONSE_TEXT,
|
||||
},
|
||||
{ type: 'text-end', id: 'text-1' },
|
||||
{
|
||||
type: 'finish',
|
||||
finishReason: { unified: 'stop', raw: 'stop' },
|
||||
usage: MOCK_USAGE,
|
||||
},
|
||||
]
|
||||
|
||||
return new MockLanguageModelV3({
|
||||
doGenerate: async () => createMockResult(),
|
||||
doStream: async () => ({
|
||||
stream: simulateReadableStream({ chunks }),
|
||||
}),
|
||||
}) as LanguageModel
|
||||
}
|
||||
@@ -21,10 +21,6 @@ import { logger } from '../../logger'
|
||||
import { createOpenRouterCompatibleFetch } from '../../openrouter-fetch'
|
||||
import { createCodexFetch } from '../oauth/codex-fetch'
|
||||
import { createCopilotFetch } from '../oauth/copilot-fetch'
|
||||
import {
|
||||
createMockBrowserOSLanguageModel,
|
||||
shouldUseMockBrowserOSLLM,
|
||||
} from './mock-language-model'
|
||||
import type { ResolvedLLMConfig } from './types'
|
||||
|
||||
type ProviderFactory = (config: ResolvedLLMConfig) => LanguageModel
|
||||
@@ -199,9 +195,6 @@ const PROVIDER_FACTORIES: Record<string, ProviderFactory> = {
|
||||
}
|
||||
|
||||
export function createLLMProvider(config: ResolvedLLMConfig): LanguageModel {
|
||||
if (shouldUseMockBrowserOSLLM(config)) {
|
||||
return createMockBrowserOSLanguageModel()
|
||||
}
|
||||
const factory = PROVIDER_FACTORIES[config.provider]
|
||||
if (!factory) throw new Error(`Unknown provider: ${config.provider}`)
|
||||
return factory(config)
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { ContainerCliError } from '../vm/errors'
|
||||
import { LimaCli } from '../vm/lima-cli'
|
||||
import type { ContainerSpec, LogFn, MountSpec, PortMapping } from './types'
|
||||
|
||||
export function buildNerdctlCommand(args: string[]): string[] {
|
||||
return ['nerdctl', ...args]
|
||||
}
|
||||
|
||||
export interface ContainerCliConfig {
|
||||
limactlPath: string
|
||||
limaHome: string
|
||||
vmName: string
|
||||
sshPath?: string
|
||||
}
|
||||
|
||||
export interface ContainerCommandResult {
|
||||
exitCode: number
|
||||
stdout: string
|
||||
stderr: string
|
||||
}
|
||||
|
||||
export class ContainerCli {
|
||||
private readonly lima: LimaCli
|
||||
|
||||
constructor(private readonly cfg: ContainerCliConfig) {
|
||||
this.lima = new LimaCli({
|
||||
limactlPath: cfg.limactlPath,
|
||||
limaHome: cfg.limaHome,
|
||||
sshPath: cfg.sshPath,
|
||||
})
|
||||
}
|
||||
|
||||
async imageExists(ref: string): Promise<boolean> {
|
||||
const result = await this.runCommand(['image', 'inspect', ref])
|
||||
return result.exitCode === 0
|
||||
}
|
||||
|
||||
async pullImage(ref: string, onLog?: LogFn): Promise<void> {
|
||||
await this.runRequired(['pull', ref], onLog)
|
||||
}
|
||||
|
||||
async loadImage(tarballPath: string, onLog?: LogFn): Promise<string[]> {
|
||||
const result = await this.runRequired(['load', '-i', tarballPath], onLog)
|
||||
return parseLoadedImageRefs(result.stdout)
|
||||
}
|
||||
|
||||
async createContainer(spec: ContainerSpec, onLog?: LogFn): Promise<void> {
|
||||
await this.runRequired(buildCreateArgs(spec), onLog)
|
||||
}
|
||||
|
||||
async startContainer(name: string, onLog?: LogFn): Promise<void> {
|
||||
await this.runRequired(['start', name], onLog)
|
||||
}
|
||||
|
||||
async stopContainer(name: string, onLog?: LogFn): Promise<void> {
|
||||
const result = await this.runCommand(['stop', name], onLog)
|
||||
if (result.exitCode === 0 || isNoSuchContainer(result.stderr)) return
|
||||
throw this.commandError(['stop', name], result)
|
||||
}
|
||||
|
||||
async removeContainer(
|
||||
name: string,
|
||||
opts?: { force?: boolean },
|
||||
onLog?: LogFn,
|
||||
): Promise<void> {
|
||||
const args = ['rm']
|
||||
if (opts?.force) args.push('-f')
|
||||
args.push(name)
|
||||
const result = await this.runCommand(args, onLog)
|
||||
if (result.exitCode === 0 || isNoSuchContainer(result.stderr)) return
|
||||
throw this.commandError(args, result)
|
||||
}
|
||||
|
||||
async exec(name: string, cmd: string[], onLog?: LogFn): Promise<number> {
|
||||
const result = await this.runCommand(['exec', name, ...cmd], onLog)
|
||||
return result.exitCode
|
||||
}
|
||||
|
||||
async ps(opts?: { namesOnly?: boolean }): Promise<string[]> {
|
||||
const args = opts?.namesOnly ? ['ps', '--format', '{{.Names}}'] : ['ps']
|
||||
const result = await this.runRequired(args)
|
||||
return result.stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
tailLogs(name: string, onLine: LogFn): () => void {
|
||||
const proc = this.lima.spawnShell(
|
||||
this.cfg.vmName,
|
||||
buildNerdctlCommand(['logs', '-f', '-n', '0', name]),
|
||||
{ onStdout: onLine, onStderr: onLine },
|
||||
)
|
||||
|
||||
let stopped = false
|
||||
return () => {
|
||||
if (stopped) return
|
||||
stopped = true
|
||||
proc.kill()
|
||||
}
|
||||
}
|
||||
|
||||
async runCommand(
|
||||
args: string[],
|
||||
onLog?: LogFn,
|
||||
): Promise<ContainerCommandResult> {
|
||||
const stdoutLines: string[] = []
|
||||
const stderrLines: string[] = []
|
||||
const exitCode = await this.lima.shell(
|
||||
this.cfg.vmName,
|
||||
buildNerdctlCommand(args),
|
||||
{
|
||||
onStdout: (line) => {
|
||||
stdoutLines.push(line)
|
||||
onLog?.(line)
|
||||
},
|
||||
onStderr: (line) => {
|
||||
stderrLines.push(line)
|
||||
onLog?.(line)
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
exitCode,
|
||||
stdout: linesToOutput(stdoutLines),
|
||||
stderr: stderrLines.join('\n'),
|
||||
}
|
||||
}
|
||||
|
||||
private async runRequired(
|
||||
args: string[],
|
||||
onLog?: LogFn,
|
||||
): Promise<ContainerCommandResult> {
|
||||
const result = await this.runCommand(args, onLog)
|
||||
if (result.exitCode === 0) return result
|
||||
throw this.commandError(args, result)
|
||||
}
|
||||
|
||||
private commandError(
|
||||
args: string[],
|
||||
result: ContainerCommandResult,
|
||||
): ContainerCliError {
|
||||
return new ContainerCliError(
|
||||
`nerdctl ${args.join(' ')}`,
|
||||
result.exitCode,
|
||||
result.stderr.trim(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function buildCreateArgs(spec: ContainerSpec): string[] {
|
||||
const args = ['create', '--name', spec.name]
|
||||
|
||||
if (spec.restart) args.push('--restart', spec.restart)
|
||||
for (const port of spec.ports ?? []) args.push('-p', portArg(port))
|
||||
if (spec.envFile) args.push('--env-file', spec.envFile)
|
||||
for (const [key, value] of Object.entries(spec.env ?? {})) {
|
||||
args.push('-e', `${key}=${value}`)
|
||||
}
|
||||
for (const mount of spec.mounts ?? []) args.push('-v', mountArg(mount))
|
||||
for (const host of spec.addHosts ?? []) args.push('--add-host', host)
|
||||
if (spec.health) {
|
||||
args.push('--health-cmd', spec.health.cmd)
|
||||
if (spec.health.interval)
|
||||
args.push('--health-interval', spec.health.interval)
|
||||
if (spec.health.timeout) args.push('--health-timeout', spec.health.timeout)
|
||||
if (spec.health.retries !== undefined) {
|
||||
args.push('--health-retries', String(spec.health.retries))
|
||||
}
|
||||
}
|
||||
|
||||
args.push(spec.image)
|
||||
args.push(...(spec.command ?? []))
|
||||
return args
|
||||
}
|
||||
|
||||
function portArg(port: PortMapping): string {
|
||||
const host = port.hostIp ? `${port.hostIp}:${port.hostPort}` : port.hostPort
|
||||
return `${host}:${port.containerPort}`
|
||||
}
|
||||
|
||||
function mountArg(mount: MountSpec): string {
|
||||
return `${mount.source}:${mount.target}${mount.readonly ? ':ro' : ''}`
|
||||
}
|
||||
|
||||
function parseLoadedImageRefs(stdout: string): string[] {
|
||||
return stdout
|
||||
.split('\n')
|
||||
.map((line) => line.match(/^Loaded image(?:\(s\))?:\s*(.+)$/i)?.[1]?.trim())
|
||||
.filter((ref): ref is string => !!ref)
|
||||
}
|
||||
|
||||
function isNoSuchContainer(stderr: string): boolean {
|
||||
const lower = stderr.toLowerCase()
|
||||
return lower.includes('no such container') || lower.includes('not found')
|
||||
}
|
||||
|
||||
function linesToOutput(lines: string[]): string {
|
||||
if (lines.length === 0) return ''
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { basename, join } from 'node:path'
|
||||
import { ContainerCliError, ImageLoadError } from '../vm/errors'
|
||||
import type { VmManifest } from '../vm/manifest'
|
||||
import type { Arch } from '../vm/paths'
|
||||
import { getImageCacheDir, hostPathToGuest } from '../vm/paths'
|
||||
import type { ContainerCli } from './container-cli'
|
||||
import type { LogFn } from './types'
|
||||
|
||||
export class ImageLoader {
|
||||
constructor(
|
||||
private readonly cli: ContainerCli,
|
||||
private readonly manifest: VmManifest,
|
||||
private readonly arch: Arch,
|
||||
private readonly browserosRoot?: string,
|
||||
) {}
|
||||
|
||||
async ensureImageLoaded(ref: string, onLog?: LogFn): Promise<void> {
|
||||
if (await this.cli.imageExists(ref)) return
|
||||
|
||||
const tarball = this.resolveTarball(ref)
|
||||
const hostPath = join(
|
||||
getImageCacheDir(this.browserosRoot),
|
||||
basename(tarball.key),
|
||||
)
|
||||
const guestPath = hostPathToGuest(hostPath, this.browserosRoot)
|
||||
|
||||
try {
|
||||
await this.cli.loadImage(guestPath, onLog)
|
||||
} catch (error) {
|
||||
if (error instanceof ContainerCliError) {
|
||||
throw new ImageLoadError(ref, `load failed: ${error.stderr}`, error)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
if (!(await this.cli.imageExists(ref))) {
|
||||
throw new ImageLoadError(
|
||||
ref,
|
||||
`image not present after successful load of ${guestPath}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private resolveTarball(
|
||||
ref: string,
|
||||
): VmManifest['agents'][string]['tarballs'][Arch] {
|
||||
for (const agent of Object.values(this.manifest.agents)) {
|
||||
if (`${agent.image}:${agent.version}` !== ref) continue
|
||||
const tarball = agent.tarballs[this.arch]
|
||||
if (!tarball) {
|
||||
throw new ImageLoadError(ref, `no ${this.arch} tarball in manifest`)
|
||||
}
|
||||
return tarball
|
||||
}
|
||||
|
||||
throw new ImageLoadError(ref, `no agent in manifest matches ${ref}`)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export * from './container-cli'
|
||||
export * from './image-loader'
|
||||
export * from './types'
|
||||
@@ -1,44 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export type LogFn = (msg: string) => void
|
||||
|
||||
export interface PortMapping {
|
||||
hostIp?: string
|
||||
hostPort: number
|
||||
containerPort: number
|
||||
}
|
||||
|
||||
export interface MountSpec {
|
||||
source: string
|
||||
target: string
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
export interface HealthConfig {
|
||||
cmd: string
|
||||
interval?: string
|
||||
timeout?: string
|
||||
retries?: number
|
||||
}
|
||||
|
||||
export interface ContainerSpec {
|
||||
name: string
|
||||
image: string
|
||||
restart?: 'no' | 'unless-stopped' | 'always'
|
||||
ports?: PortMapping[]
|
||||
env?: Record<string, string>
|
||||
envFile?: string
|
||||
mounts?: MountSpec[]
|
||||
addHosts?: string[]
|
||||
health?: HealthConfig
|
||||
command?: string[]
|
||||
}
|
||||
|
||||
export interface LogLine {
|
||||
stream: 'stdout' | 'stderr'
|
||||
line: string
|
||||
}
|
||||
@@ -1,322 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createReadStream, existsSync } from 'node:fs'
|
||||
import { mkdir, readFile, rename, rm } from 'node:fs/promises'
|
||||
import { arch as hostArch } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { EXTERNAL_URLS } from '@browseros/shared/constants/urls'
|
||||
import type { VmArtifact, VmManifest } from './manifest'
|
||||
import type { Arch } from './paths'
|
||||
import { getCachedManifestPath } from './paths'
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000
|
||||
const ARCHES: Arch[] = ['arm64', 'x64']
|
||||
const CANONICAL_MANIFEST_SUFFIX = '/vm/manifest.json'
|
||||
|
||||
export interface VmCacheSyncOptions {
|
||||
browserosRoot?: string
|
||||
manifestUrl?: string
|
||||
allArches?: boolean
|
||||
fetchImpl?: typeof fetch
|
||||
rawHostArch?: NodeJS.Architecture
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
export interface VmCacheSyncResult {
|
||||
downloaded: string[]
|
||||
manifestPath: string
|
||||
skipped: boolean
|
||||
}
|
||||
|
||||
const inFlight = new Map<string, Promise<VmCacheSyncResult>>()
|
||||
|
||||
export function prefetchVmCache(
|
||||
options: VmCacheSyncOptions = {},
|
||||
): Promise<VmCacheSyncResult> {
|
||||
return startOrReuseSync(options)
|
||||
}
|
||||
|
||||
export function ensureVmCacheSynced(
|
||||
options: VmCacheSyncOptions = {},
|
||||
): Promise<VmCacheSyncResult> {
|
||||
return startOrReuseSync(options)
|
||||
}
|
||||
|
||||
export async function ensureVmCacheAvailable(
|
||||
options: VmCacheSyncOptions = {},
|
||||
): Promise<void> {
|
||||
const cfg = resolveSyncConfig(options)
|
||||
const pending = inFlight.get(syncKey(cfg))
|
||||
if (pending) {
|
||||
await pending.catch(() => {})
|
||||
}
|
||||
|
||||
if (existsSync(getCachedManifestPath(cfg.browserosRoot))) return
|
||||
|
||||
await startOrReuseSyncWithConfig(cfg)
|
||||
}
|
||||
|
||||
function startOrReuseSync(
|
||||
options: VmCacheSyncOptions,
|
||||
): Promise<VmCacheSyncResult> {
|
||||
try {
|
||||
return startOrReuseSyncWithConfig(resolveSyncConfig(options))
|
||||
} catch (error) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
function startOrReuseSyncWithConfig(
|
||||
cfg: SyncConfig,
|
||||
): Promise<VmCacheSyncResult> {
|
||||
const key = syncKey(cfg)
|
||||
const existing = inFlight.get(key)
|
||||
if (existing) return existing
|
||||
const current = syncVmCache(cfg).finally(() => {
|
||||
if (inFlight.get(key) === current) inFlight.delete(key)
|
||||
})
|
||||
inFlight.set(key, current)
|
||||
return current
|
||||
}
|
||||
|
||||
async function syncVmCache(cfg: SyncConfig): Promise<VmCacheSyncResult> {
|
||||
const remote = await fetchManifest(cfg)
|
||||
const manifestPath = getCachedManifestPath(cfg.browserosRoot)
|
||||
const local = await readLocalManifest(manifestPath)
|
||||
const plan = await planDownloads({
|
||||
remote,
|
||||
local,
|
||||
cacheRoot: cacheRootForManifest(manifestPath),
|
||||
arches: cfg.arches,
|
||||
})
|
||||
|
||||
for (const item of plan) {
|
||||
await downloadArtifact(
|
||||
cfg.fetchImpl,
|
||||
artifactUrlForKey(cfg.manifestUrl, item.key),
|
||||
item.destPath,
|
||||
item.sha256,
|
||||
cfg.timeoutMs,
|
||||
)
|
||||
}
|
||||
|
||||
await mkdir(dirname(manifestPath), { recursive: true })
|
||||
const tempPath = `${manifestPath}.${process.pid}.${Date.now()}.tmp`
|
||||
await Bun.write(tempPath, `${JSON.stringify(remote, null, 2)}\n`)
|
||||
await rename(tempPath, manifestPath)
|
||||
|
||||
return {
|
||||
downloaded: plan.map((item) => item.key),
|
||||
manifestPath,
|
||||
skipped: plan.length === 0,
|
||||
}
|
||||
}
|
||||
|
||||
interface SyncConfig {
|
||||
browserosRoot?: string
|
||||
manifestUrl: string
|
||||
fetchImpl: typeof fetch
|
||||
arches: Arch[]
|
||||
timeoutMs: number
|
||||
}
|
||||
|
||||
function resolveSyncConfig(options: VmCacheSyncOptions): SyncConfig {
|
||||
return {
|
||||
browserosRoot: options.browserosRoot,
|
||||
manifestUrl:
|
||||
trimNonEmpty(options.manifestUrl) ??
|
||||
trimNonEmpty(process.env.BROWSEROS_VM_CACHE_MANIFEST_URL) ??
|
||||
EXTERNAL_URLS.VM_CACHE_MANIFEST,
|
||||
fetchImpl: options.fetchImpl ?? fetch,
|
||||
arches: selectSyncArches(options),
|
||||
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchManifest(cfg: SyncConfig): Promise<VmManifest> {
|
||||
const response = await fetchWithTimeout(
|
||||
cfg.fetchImpl,
|
||||
cfg.manifestUrl,
|
||||
cfg.timeoutMs,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`manifest fetch failed: ${cfg.manifestUrl} (${response.status})`,
|
||||
)
|
||||
}
|
||||
return (await response.json()) as VmManifest
|
||||
}
|
||||
|
||||
interface DownloadPlanItem {
|
||||
key: string
|
||||
destPath: string
|
||||
sha256: string
|
||||
}
|
||||
|
||||
async function planDownloads(opts: {
|
||||
remote: VmManifest
|
||||
local: VmManifest | null
|
||||
cacheRoot: string
|
||||
arches: Arch[]
|
||||
}): Promise<DownloadPlanItem[]> {
|
||||
const out: DownloadPlanItem[] = []
|
||||
for (const arch of opts.arches) {
|
||||
for (const [name, agent] of Object.entries(opts.remote.agents)) {
|
||||
const remote = agent.tarballs[arch]
|
||||
if (!remote) continue
|
||||
const destPath = join(opts.cacheRoot, remote.key)
|
||||
if (
|
||||
!(await needsDownload(
|
||||
remote,
|
||||
opts.local?.agents[name]?.tarballs[arch],
|
||||
destPath,
|
||||
))
|
||||
) {
|
||||
continue
|
||||
}
|
||||
out.push({ key: remote.key, destPath, sha256: remote.sha256 })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function needsDownload(
|
||||
remote: VmArtifact,
|
||||
local: VmArtifact | undefined,
|
||||
destPath: string,
|
||||
): Promise<boolean> {
|
||||
if (!existsSync(destPath)) return true
|
||||
if (local?.sha256 === remote.sha256) return false
|
||||
try {
|
||||
return (await sha256File(destPath)) !== remote.sha256
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadArtifact(
|
||||
fetchImpl: typeof fetch,
|
||||
url: string,
|
||||
destPath: string,
|
||||
sha256: string,
|
||||
timeoutMs: number,
|
||||
): Promise<void> {
|
||||
const partialPath = `${destPath}.partial`
|
||||
await mkdir(dirname(destPath), { recursive: true })
|
||||
await rm(partialPath, { force: true })
|
||||
|
||||
try {
|
||||
const response = await fetchWithTimeout(fetchImpl, url, timeoutMs)
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`download failed: ${url} (${response.status})`)
|
||||
}
|
||||
|
||||
const sink = Bun.file(partialPath).writer()
|
||||
const reader = response.body.getReader()
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
sink.write(value)
|
||||
}
|
||||
} finally {
|
||||
await sink.end()
|
||||
}
|
||||
|
||||
await verifySha256(partialPath, sha256)
|
||||
await rename(partialPath, destPath)
|
||||
} catch (error) {
|
||||
await rm(partialPath, { force: true })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(
|
||||
fetchImpl: typeof fetch,
|
||||
url: string,
|
||||
timeoutMs: number,
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||
try {
|
||||
return await fetchImpl(url, { signal: controller.signal })
|
||||
} catch (error) {
|
||||
if ((error as { name?: string }).name === 'AbortError') {
|
||||
throw new Error(`fetch timed out after ${timeoutMs}ms: ${url}`)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
async function verifySha256(path: string, expected: string): Promise<void> {
|
||||
const actual = await sha256File(path)
|
||||
if (actual !== expected) {
|
||||
throw new Error(
|
||||
`sha256 mismatch for ${path}: expected ${expected}, got ${actual}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function sha256File(path: string): Promise<string> {
|
||||
const hash = createHash('sha256')
|
||||
for await (const chunk of createReadStream(path)) {
|
||||
hash.update(chunk)
|
||||
}
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
async function readLocalManifest(path: string): Promise<VmManifest | null> {
|
||||
try {
|
||||
return JSON.parse(await readFile(path, 'utf8')) as VmManifest
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function selectSyncArches(options: VmCacheSyncOptions): Arch[] {
|
||||
if (options.allArches) return [...ARCHES]
|
||||
const rawArch = options.rawHostArch ?? hostArch()
|
||||
if (rawArch === 'arm64') return ['arm64']
|
||||
if (rawArch === 'x64' || rawArch === 'ia32') return ['x64']
|
||||
throw new Error(`unsupported host arch: ${rawArch}`)
|
||||
}
|
||||
|
||||
function cacheRootForManifest(manifestPath: string): string {
|
||||
return dirname(dirname(manifestPath))
|
||||
}
|
||||
|
||||
function syncKey(cfg: SyncConfig): string {
|
||||
return [
|
||||
getCachedManifestPath(cfg.browserosRoot),
|
||||
cfg.manifestUrl,
|
||||
cfg.arches.join(','),
|
||||
String(cfg.timeoutMs),
|
||||
].join('\0')
|
||||
}
|
||||
|
||||
function artifactUrlForKey(manifestUrl: string, key: string): string {
|
||||
const artifactKey = key.replace(/^\/+/, '')
|
||||
const url = new URL(manifestUrl)
|
||||
const normalizedPath = url.pathname.replace(/\/+$/, '')
|
||||
const prefix = normalizedPath.endsWith(CANONICAL_MANIFEST_SUFFIX)
|
||||
? normalizedPath.slice(0, -CANONICAL_MANIFEST_SUFFIX.length)
|
||||
: normalizedPath.slice(0, Math.max(0, normalizedPath.lastIndexOf('/')))
|
||||
|
||||
url.pathname = `${prefix.replace(/\/+$/, '')}/${artifactKey}`
|
||||
url.search = ''
|
||||
url.hash = ''
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
function trimNonEmpty(value: string | undefined): string | undefined {
|
||||
const trimmed = value?.trim()
|
||||
return trimmed ? trimmed : undefined
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export class VmError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = new.target.name
|
||||
}
|
||||
}
|
||||
|
||||
export class VmNotReadyError extends VmError {}
|
||||
|
||||
export class VmStateCorruptedError extends VmError {}
|
||||
|
||||
export class LimaCommandError extends VmError {
|
||||
constructor(
|
||||
command: string,
|
||||
public readonly exitCode: number,
|
||||
public readonly stderr: string,
|
||||
) {
|
||||
super(`${command} failed with exit code ${exitCode}: ${stderr}`)
|
||||
}
|
||||
}
|
||||
|
||||
export class ContainerCliError extends VmError {
|
||||
constructor(
|
||||
command: string,
|
||||
public readonly exitCode: number,
|
||||
public readonly stderr: string,
|
||||
) {
|
||||
super(`${command} failed with exit code ${exitCode}: ${stderr}`)
|
||||
}
|
||||
}
|
||||
|
||||
export class ImageLoadError extends VmError {
|
||||
constructor(
|
||||
public readonly imageRef: string,
|
||||
message: string,
|
||||
public override readonly cause?: unknown,
|
||||
) {
|
||||
super(`failed to load image ${imageRef}: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
export class ManifestMissingError extends VmError {
|
||||
constructor(public readonly manifestPath: string) {
|
||||
super(manifestMissingMessage(manifestPath))
|
||||
}
|
||||
}
|
||||
|
||||
function manifestMissingMessage(manifestPath: string): string {
|
||||
const message = `VM manifest is missing at ${manifestPath}`
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return `${message}; run bun run dev:setup before starting the server`
|
||||
}
|
||||
return message
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export * from './errors'
|
||||
export * from './lima-cli'
|
||||
export * from './lima-config'
|
||||
export * from './manifest'
|
||||
export * from './paths'
|
||||
export * from './telemetry'
|
||||
export * from './vm-runtime'
|
||||
@@ -1,270 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { logger } from '../logger'
|
||||
import { LimaCommandError, VmNotReadyError } from './errors'
|
||||
import { getLimaSshConfigPath } from './paths'
|
||||
import { VM_TELEMETRY_EVENTS } from './telemetry'
|
||||
|
||||
export interface LimaListEntry {
|
||||
name: string
|
||||
status: string
|
||||
dir: string
|
||||
}
|
||||
|
||||
export interface LimaCliConfig {
|
||||
limactlPath: string
|
||||
limaHome: string
|
||||
sshPath?: string
|
||||
}
|
||||
|
||||
export interface LimaShellStreams {
|
||||
onStdout?: (line: string) => void
|
||||
onStderr?: (line: string) => void
|
||||
}
|
||||
|
||||
export interface LimaShellProcess {
|
||||
kill: () => void
|
||||
exited: Promise<number>
|
||||
}
|
||||
|
||||
const LIMA_VERBOSE_LOGGING = false
|
||||
|
||||
export class LimaCli {
|
||||
constructor(private readonly cfg: LimaCliConfig) {}
|
||||
|
||||
async list(): Promise<LimaListEntry[]> {
|
||||
const result = await this.run(['list', '--format', 'json'])
|
||||
if (!result.stdout.trim()) {
|
||||
logger.debug('Lima list returned no instances', {
|
||||
limaHome: this.cfg.limaHome,
|
||||
})
|
||||
return []
|
||||
}
|
||||
const entries = parseLimaList(result.stdout)
|
||||
logger.debug('Lima list parsed', {
|
||||
limaHome: this.cfg.limaHome,
|
||||
count: entries.length,
|
||||
entries: entries.map((e) => ({ name: e.name, status: e.status })),
|
||||
})
|
||||
return entries
|
||||
}
|
||||
|
||||
async create(name: string, yamlPath: string): Promise<void> {
|
||||
await this.runChecked('create', [
|
||||
'create',
|
||||
'--tty=false',
|
||||
`--name=${name}`,
|
||||
yamlPath,
|
||||
])
|
||||
}
|
||||
|
||||
async start(name: string): Promise<void> {
|
||||
logger.info('Invoking limactl start', {
|
||||
vmName: name,
|
||||
limaHome: this.cfg.limaHome,
|
||||
note: 'this command blocks until boot reaches READY; may take 40-120s on first boot',
|
||||
})
|
||||
await this.runChecked('start', ['start', '--tty=false', name])
|
||||
}
|
||||
|
||||
async stop(name: string): Promise<void> {
|
||||
await this.runChecked('stop', ['stop', name])
|
||||
}
|
||||
|
||||
async delete(name: string): Promise<void> {
|
||||
await this.runChecked('delete', ['delete', '--force', name])
|
||||
}
|
||||
|
||||
async shell(
|
||||
name: string,
|
||||
args: string[],
|
||||
streams?: LimaShellStreams,
|
||||
): Promise<number> {
|
||||
const proc = this.spawnShell(name, args, streams)
|
||||
return proc.exited
|
||||
}
|
||||
|
||||
spawnShell(
|
||||
name: string,
|
||||
args: string[],
|
||||
streams?: LimaShellStreams,
|
||||
): LimaShellProcess {
|
||||
const configPath = getLimaSshConfigPath(this.cfg.limaHome, name)
|
||||
if (!existsSync(configPath)) {
|
||||
throw new VmNotReadyError(
|
||||
`lima ssh.config not found at ${configPath}; VM has not been started`,
|
||||
)
|
||||
}
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
this.cfg.sshPath ?? 'ssh',
|
||||
'-F',
|
||||
configPath,
|
||||
`lima-${name}`,
|
||||
shellQuoteCommand(args),
|
||||
],
|
||||
{
|
||||
cwd: '/',
|
||||
env: this.env(),
|
||||
stdout: streams?.onStdout ? 'pipe' : 'ignore',
|
||||
stderr: streams?.onStderr ? 'pipe' : 'ignore',
|
||||
},
|
||||
)
|
||||
|
||||
const drained = Promise.all([
|
||||
drainStream(proc.stdout ?? null, streams?.onStdout),
|
||||
drainStream(proc.stderr ?? null, streams?.onStderr),
|
||||
])
|
||||
const exited = drained.then(() => proc.exited)
|
||||
return {
|
||||
exited,
|
||||
kill: () => {
|
||||
try {
|
||||
proc.kill()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
private async runChecked(command: string, args: string[]): Promise<void> {
|
||||
const result = await this.run(args)
|
||||
if (result.exitCode !== 0) {
|
||||
throw new LimaCommandError(
|
||||
`limactl ${command}`,
|
||||
result.exitCode,
|
||||
result.stderr,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private async run(args: string[]): Promise<{
|
||||
exitCode: number
|
||||
stdout: string
|
||||
stderr: string
|
||||
}> {
|
||||
const started = Date.now()
|
||||
const proc = Bun.spawn([this.cfg.limactlPath, ...args], {
|
||||
env: this.env(),
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
})
|
||||
logger.debug(VM_TELEMETRY_EVENTS.limaSpawn, {
|
||||
pid: proc.pid,
|
||||
args,
|
||||
limaHome: this.cfg.limaHome,
|
||||
})
|
||||
|
||||
const stderrLogger = LIMA_VERBOSE_LOGGING
|
||||
? (line: string) => {
|
||||
logger.debug(VM_TELEMETRY_EVENTS.limaStderrChunk, {
|
||||
pid: proc.pid,
|
||||
firstArg: args[0],
|
||||
line,
|
||||
})
|
||||
}
|
||||
: undefined
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
drainToString(proc.stdout),
|
||||
drainToString(proc.stderr, stderrLogger),
|
||||
proc.exited,
|
||||
])
|
||||
const durationMs = Date.now() - started
|
||||
logger.debug(VM_TELEMETRY_EVENTS.limaExit, {
|
||||
pid: proc.pid,
|
||||
firstArg: args[0],
|
||||
exitCode,
|
||||
durationMs,
|
||||
stdoutLen: stdout.length,
|
||||
stderrLen: stderr.length,
|
||||
})
|
||||
return { exitCode, stdout, stderr }
|
||||
}
|
||||
|
||||
private env(): NodeJS.ProcessEnv {
|
||||
return { ...process.env, LIMA_HOME: this.cfg.limaHome }
|
||||
}
|
||||
}
|
||||
|
||||
async function drainToString(
|
||||
stream: ReadableStream<Uint8Array> | null,
|
||||
onLine?: (line: string) => void,
|
||||
): Promise<string> {
|
||||
if (!stream) return ''
|
||||
const reader = stream.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
let output = ''
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
const chunk = decoder.decode(value, { stream: true })
|
||||
output += chunk
|
||||
buffer += chunk
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() ?? ''
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed && onLine) onLine(trimmed)
|
||||
}
|
||||
}
|
||||
if (buffer.trim() && onLine) onLine(buffer.trim())
|
||||
return output
|
||||
}
|
||||
|
||||
function parseLimaList(output: string): LimaListEntry[] {
|
||||
const trimmed = output.trim()
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as unknown
|
||||
if (Array.isArray(parsed)) return parsed.map(toLimaListEntry)
|
||||
return [toLimaListEntry(parsed)]
|
||||
} catch {
|
||||
return trimmed.split('\n').map((line) => toLimaListEntry(JSON.parse(line)))
|
||||
}
|
||||
}
|
||||
|
||||
function toLimaListEntry(input: unknown): LimaListEntry {
|
||||
const entry = input as Partial<LimaListEntry>
|
||||
return {
|
||||
name: entry.name ?? '',
|
||||
status: entry.status ?? '',
|
||||
dir: entry.dir ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
function shellQuoteCommand(args: string[]): string {
|
||||
return args.map(shellQuote).join(' ')
|
||||
}
|
||||
|
||||
function shellQuote(arg: string): string {
|
||||
return `'${arg.replaceAll("'", "'\\''")}'`
|
||||
}
|
||||
|
||||
async function drainStream(
|
||||
stream: ReadableStream<Uint8Array> | null,
|
||||
onLine?: (line: string) => void,
|
||||
): Promise<void> {
|
||||
if (!stream || !onLine) return
|
||||
const reader = stream.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() ?? ''
|
||||
for (const line of lines) {
|
||||
if (line.trim()) onLine(line.trim())
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.trim()) onLine(buffer.trim())
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export function renderLimaTemplate(
|
||||
template: string,
|
||||
cfg: {
|
||||
vmStateDir: string
|
||||
imageCacheDir: string
|
||||
},
|
||||
): string {
|
||||
const mounts = [
|
||||
'mounts:',
|
||||
`- location: "${cfg.vmStateDir}"`,
|
||||
' mountPoint: "/mnt/browseros/vm"',
|
||||
' writable: true',
|
||||
`- location: "${cfg.imageCacheDir}"`,
|
||||
' mountPoint: "/mnt/browseros/cache/images"',
|
||||
' writable: false',
|
||||
].join('\n')
|
||||
|
||||
if (!template.includes('mounts: []')) {
|
||||
throw new Error('BrowserOS VM Lima template is missing mounts: [] marker')
|
||||
}
|
||||
|
||||
return template.replace('mounts: []', mounts)
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
||||
import { dirname } from 'node:path'
|
||||
import { ManifestMissingError } from './errors'
|
||||
import type { Arch } from './paths'
|
||||
import { getCachedManifestPath, getInstalledManifestPath } from './paths'
|
||||
|
||||
export interface VmArtifact {
|
||||
key: string
|
||||
sha256: string
|
||||
sizeBytes: number
|
||||
}
|
||||
|
||||
export interface VmAgentEntry {
|
||||
image: string
|
||||
version: string
|
||||
tarballs: Record<Arch, VmArtifact>
|
||||
}
|
||||
|
||||
export interface VmManifest {
|
||||
schemaVersion: number
|
||||
updatedAt: string
|
||||
agents: Record<string, VmAgentEntry>
|
||||
}
|
||||
|
||||
export type VersionComparison = 'same' | 'upgrade' | 'downgrade' | 'fresh'
|
||||
|
||||
export async function readCachedManifest(
|
||||
browserosRoot?: string,
|
||||
): Promise<VmManifest> {
|
||||
const manifestPath = getCachedManifestPath(browserosRoot)
|
||||
if (!existsSync(manifestPath)) throw new ManifestMissingError(manifestPath)
|
||||
return readManifest(manifestPath)
|
||||
}
|
||||
|
||||
export async function readInstalledManifest(
|
||||
browserosRoot?: string,
|
||||
): Promise<VmManifest | null> {
|
||||
const manifestPath = getInstalledManifestPath(browserosRoot)
|
||||
if (!existsSync(manifestPath)) return null
|
||||
return readManifest(manifestPath)
|
||||
}
|
||||
|
||||
export async function writeInstalledManifest(
|
||||
manifest: VmManifest,
|
||||
browserosRoot?: string,
|
||||
): Promise<void> {
|
||||
const manifestPath = getInstalledManifestPath(browserosRoot)
|
||||
await mkdir(dirname(manifestPath), { recursive: true })
|
||||
const tempPath = `${manifestPath}.${process.pid}.${Date.now()}.tmp`
|
||||
await writeFile(tempPath, `${JSON.stringify(manifest, null, 2)}\n`)
|
||||
await rename(tempPath, manifestPath)
|
||||
}
|
||||
|
||||
export function compareVersions(
|
||||
installed: VmManifest | null,
|
||||
cached: VmManifest,
|
||||
): VersionComparison {
|
||||
if (!installed) return 'fresh'
|
||||
const comparison = compareVersionStrings(
|
||||
installed.updatedAt,
|
||||
cached.updatedAt,
|
||||
)
|
||||
if (comparison === 0) return 'same'
|
||||
return comparison < 0 ? 'upgrade' : 'downgrade'
|
||||
}
|
||||
|
||||
export function agentForArch(
|
||||
manifest: VmManifest,
|
||||
name: string,
|
||||
arch: Arch,
|
||||
): {
|
||||
image: string
|
||||
version: string
|
||||
tarball: VmManifest['agents'][string]['tarballs'][Arch]
|
||||
} {
|
||||
const agent = manifest.agents[name]
|
||||
if (!agent) throw new Error(`missing agent in VM manifest: ${name}`)
|
||||
const tarball = agent.tarballs[arch]
|
||||
if (!tarball) throw new Error(`missing ${arch} tarball for agent ${name}`)
|
||||
return {
|
||||
image: agent.image,
|
||||
version: agent.version,
|
||||
tarball,
|
||||
}
|
||||
}
|
||||
|
||||
async function readManifest(path: string): Promise<VmManifest> {
|
||||
return JSON.parse(await readFile(path, 'utf8')) as VmManifest
|
||||
}
|
||||
|
||||
function compareVersionStrings(left: string, right: string): number {
|
||||
if (left < right) return -1
|
||||
if (left > right) return 1
|
||||
return 0
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { accessSync, constants, existsSync } from 'node:fs'
|
||||
import { homedir, arch as osArch } from 'node:os'
|
||||
import {
|
||||
delimiter,
|
||||
dirname,
|
||||
isAbsolute,
|
||||
join,
|
||||
relative,
|
||||
resolve,
|
||||
sep,
|
||||
} from 'node:path'
|
||||
import { PATHS } from '@browseros/shared/constants/paths'
|
||||
|
||||
export const VM_NAME = 'browseros-vm'
|
||||
export const GUEST_VM_STATE = '/mnt/browseros/vm'
|
||||
export const GUEST_IMAGE_CACHE = '/mnt/browseros/cache/images'
|
||||
const HOST_LIMACTL_BINARY = 'limactl'
|
||||
|
||||
export type Arch = 'arm64' | 'x64'
|
||||
|
||||
function rootDir(): string {
|
||||
const override = process.env.BROWSEROS_DIR?.trim()
|
||||
if (override) {
|
||||
return override
|
||||
}
|
||||
const base =
|
||||
process.env.NODE_ENV === 'development'
|
||||
? PATHS.DEV_BROWSEROS_DIR_NAME
|
||||
: PATHS.BROWSEROS_DIR_NAME
|
||||
return join(homedir(), base)
|
||||
}
|
||||
|
||||
export function detectArch(arch: NodeJS.Architecture = osArch()): Arch {
|
||||
if (arch === 'arm64') return 'arm64'
|
||||
if (arch === 'x64') return 'x64'
|
||||
throw new Error(`unsupported host arch: ${arch}`)
|
||||
}
|
||||
|
||||
export function getLimaHomeDir(browserosRoot = rootDir()): string {
|
||||
return join(browserosRoot, 'lima')
|
||||
}
|
||||
|
||||
export function getVmStateDir(browserosRoot = rootDir()): string {
|
||||
return join(browserosRoot, 'vm')
|
||||
}
|
||||
|
||||
export function getVmCacheDir(browserosRoot = rootDir()): string {
|
||||
return join(browserosRoot, PATHS.CACHE_DIR_NAME, 'vm')
|
||||
}
|
||||
|
||||
export function getImageCacheDir(browserosRoot = rootDir()): string {
|
||||
return join(getVmCacheDir(browserosRoot), 'images')
|
||||
}
|
||||
|
||||
export function getCachedManifestPath(browserosRoot = rootDir()): string {
|
||||
return join(getVmCacheDir(browserosRoot), 'manifest.json')
|
||||
}
|
||||
|
||||
export function getInstalledManifestPath(browserosRoot = rootDir()): string {
|
||||
return join(getVmStateDir(browserosRoot), 'manifest.json')
|
||||
}
|
||||
|
||||
export function getContainerdSocketPath(browserosRoot = rootDir()): string {
|
||||
return join(getLimaHomeDir(browserosRoot), VM_NAME, 'sock', 'containerd.sock')
|
||||
}
|
||||
|
||||
export function getLimaSocketPath(browserosRoot = rootDir()): string {
|
||||
return getContainerdSocketPath(browserosRoot)
|
||||
}
|
||||
|
||||
export function getLimaSshConfigPath(limaHome: string, name: string): string {
|
||||
return join(limaHome, name, 'ssh.config')
|
||||
}
|
||||
|
||||
export function compressedDiskPath(
|
||||
version: string,
|
||||
arch: Arch,
|
||||
browserosRoot = rootDir(),
|
||||
): string {
|
||||
return join(
|
||||
getVmCacheDir(browserosRoot),
|
||||
`browseros-vm-${version}-${arch}.qcow2.zst`,
|
||||
)
|
||||
}
|
||||
|
||||
export function decompressedDiskPath(
|
||||
version: string,
|
||||
arch: Arch,
|
||||
browserosRoot = rootDir(),
|
||||
): string {
|
||||
return join(
|
||||
getVmCacheDir(browserosRoot),
|
||||
`browseros-vm-${version}-${arch}.qcow2`,
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveBundledLimactl(
|
||||
resourcesDir: string,
|
||||
hostArch: Arch = detectArch(),
|
||||
): string {
|
||||
if (usesHostVmTools()) return resolveHostLimactl()
|
||||
|
||||
const limaRoot = resolveBundledLimaRoot(resourcesDir)
|
||||
const candidate = join(limaRoot, 'bin', 'limactl')
|
||||
if (!existsSync(candidate)) {
|
||||
throw new Error(
|
||||
`bundled limactl not found at ${candidate}; see the build-tools README and run bun run cache:sync`,
|
||||
)
|
||||
}
|
||||
assertBundledLimaGuestAgent(limaRoot, hostArch)
|
||||
return candidate
|
||||
}
|
||||
|
||||
function resolveBundledLimaRoot(resourcesDir: string): string {
|
||||
return join(resourcesDir, 'bin', 'third_party', 'lima')
|
||||
}
|
||||
|
||||
function nativeLinuxGuestAgentName(arch: Arch): string {
|
||||
return arch === 'arm64'
|
||||
? 'lima-guestagent.Linux-aarch64.gz'
|
||||
: 'lima-guestagent.Linux-x86_64.gz'
|
||||
}
|
||||
|
||||
function assertBundledLimaGuestAgent(limaRoot: string, hostArch: Arch): void {
|
||||
const guestAgent = join(
|
||||
limaRoot,
|
||||
'share',
|
||||
'lima',
|
||||
nativeLinuxGuestAgentName(hostArch),
|
||||
)
|
||||
if (!existsSync(guestAgent)) {
|
||||
throw new Error(
|
||||
`bundled Lima guest agent not found at ${guestAgent}; upload Lima runtime files and refresh server resources`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveHostLimactl(): string {
|
||||
const resolved = findExecutableOnPath(HOST_LIMACTL_BINARY)
|
||||
if (resolved) return resolved
|
||||
throw new Error(
|
||||
'Lima is not installed or limactl is not on PATH. Install with brew install lima.',
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveBundledLimaTemplate(resourcesDir: string): string {
|
||||
if (usesHostVmTools()) {
|
||||
const sourceTemplate = findSourceLimaTemplate(resourcesDir)
|
||||
if (sourceTemplate) return sourceTemplate
|
||||
}
|
||||
|
||||
const candidate = join(resourcesDir, 'vm', 'browseros-vm.yaml')
|
||||
if (!existsSync(candidate)) {
|
||||
throw new Error(
|
||||
`bundled Lima template not found at ${candidate}; see the build-tools README and run bun run cache:sync`,
|
||||
)
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
function usesHostVmTools(): boolean {
|
||||
return (
|
||||
process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test'
|
||||
)
|
||||
}
|
||||
|
||||
function findExecutableOnPath(binary: string): string | null {
|
||||
const pathEnv = process.env.PATH
|
||||
if (!pathEnv) return null
|
||||
for (const dir of pathEnv.split(delimiter)) {
|
||||
if (!dir) continue
|
||||
const candidate = join(dir, binary)
|
||||
try {
|
||||
accessSync(candidate, constants.X_OK)
|
||||
return candidate
|
||||
} catch {}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function findSourceLimaTemplate(resourcesDir: string): string | null {
|
||||
let current = resolve(resourcesDir)
|
||||
while (true) {
|
||||
const rootCandidate = join(
|
||||
current,
|
||||
'packages',
|
||||
'build-tools',
|
||||
'template',
|
||||
'browseros-vm.yaml',
|
||||
)
|
||||
if (existsSync(rootCandidate)) return rootCandidate
|
||||
|
||||
const packageCandidate = join(
|
||||
current,
|
||||
'build-tools',
|
||||
'template',
|
||||
'browseros-vm.yaml',
|
||||
)
|
||||
if (existsSync(packageCandidate)) return packageCandidate
|
||||
|
||||
const parent = dirname(current)
|
||||
if (parent === current) return null
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
export function hostPathToGuest(
|
||||
hostPath: string,
|
||||
browserosRoot = rootDir(),
|
||||
): string {
|
||||
const vmState = getVmStateDir(browserosRoot)
|
||||
const imageCache = getImageCacheDir(browserosRoot)
|
||||
const vmStateRelative = mountedRelativePath(vmState, hostPath)
|
||||
if (vmStateRelative !== null)
|
||||
return guestPath(GUEST_VM_STATE, vmStateRelative)
|
||||
|
||||
const imageCacheRelative = mountedRelativePath(imageCache, hostPath)
|
||||
if (imageCacheRelative !== null) {
|
||||
return guestPath(GUEST_IMAGE_CACHE, imageCacheRelative)
|
||||
}
|
||||
|
||||
throw new Error(`host path ${hostPath} is not under any known guest mount`)
|
||||
}
|
||||
|
||||
function mountedRelativePath(parent: string, child: string): string | null {
|
||||
const path = relative(parent, child)
|
||||
if (path === '') return ''
|
||||
if (path.startsWith('..') || isAbsolute(path)) return null
|
||||
return path
|
||||
}
|
||||
|
||||
function guestPath(root: string, relativePath: string): string {
|
||||
if (!relativePath) return root
|
||||
return `${root}/${relativePath.split(sep).join('/')}`
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
export const VM_TELEMETRY_EVENTS = {
|
||||
ensureReadyStart: 'vm.ensure_ready.start',
|
||||
ensureReadyOk: 'vm.ensure_ready.ok',
|
||||
ensureReadyBranch: 'vm.ensure_ready.branch',
|
||||
create: 'vm.create',
|
||||
start: 'vm.start',
|
||||
stop: 'vm.stop',
|
||||
upgradeDetected: 'vm.upgrade.detected',
|
||||
downgradeDetected: 'vm.downgrade.detected',
|
||||
upgradeSwap: 'vm.upgrade.swap',
|
||||
upgradeReplay: 'vm.upgrade.replay',
|
||||
resetDetected: 'vm.reset.detected',
|
||||
resetOk: 'vm.reset.ok',
|
||||
nerdctlWaitStart: 'vm.nerdctl_wait.start',
|
||||
nerdctlWaitOk: 'vm.nerdctl_wait.ok',
|
||||
nerdctlWaitPoll: 'vm.nerdctl_wait.poll',
|
||||
nerdctlWaitTimeout: 'vm.nerdctl_wait.timeout',
|
||||
manifestMissing: 'vm.manifest.missing',
|
||||
manifestCompared: 'vm.manifest.compared',
|
||||
manifestWritten: 'vm.manifest.written',
|
||||
migrationOpenClawMoved: 'vm.migration.openclaw_moved',
|
||||
limaSpawn: 'vm.lima.spawn',
|
||||
limaExit: 'vm.lima.exit',
|
||||
limaStderrChunk: 'vm.lima.stderr_chunk',
|
||||
provisionYamlWrite: 'vm.provision.yaml_write',
|
||||
provisionCreateStart: 'vm.provision.create.start',
|
||||
provisionCreateOk: 'vm.provision.create.ok',
|
||||
provisionStartBegin: 'vm.provision.start.begin',
|
||||
provisionStartOk: 'vm.provision.start.ok',
|
||||
} as const
|
||||
@@ -1,336 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { logger } from '../logger'
|
||||
import { ensureVmCacheAvailable } from './cache-sync'
|
||||
import { LimaCommandError, VmError, VmNotReadyError } from './errors'
|
||||
import { LimaCli } from './lima-cli'
|
||||
import { renderLimaTemplate } from './lima-config'
|
||||
import {
|
||||
compareVersions,
|
||||
readCachedManifest,
|
||||
readInstalledManifest,
|
||||
writeInstalledManifest,
|
||||
} from './manifest'
|
||||
import { getImageCacheDir, getVmStateDir, VM_NAME } from './paths'
|
||||
import { VM_TELEMETRY_EVENTS } from './telemetry'
|
||||
|
||||
export type LogFn = (msg: string) => void
|
||||
const ROOTLESS_CONTAINERD_MARKER = 'runtime:containerd-rootless'
|
||||
|
||||
export interface VmRuntimeDeps {
|
||||
limactlPath: string
|
||||
limaHome: string
|
||||
sshPath?: string
|
||||
templatePath?: string
|
||||
browserosRoot?: string
|
||||
readinessTimeoutMs?: number
|
||||
readinessPollMs?: number
|
||||
ensureCacheAvailable?: () => Promise<void>
|
||||
}
|
||||
|
||||
export class VmRuntime {
|
||||
private readonly cli: LimaCli
|
||||
private readonly readinessTimeoutMs: number
|
||||
private readonly readinessPollMs: number
|
||||
private defaultGateway: string | null = null
|
||||
|
||||
constructor(private readonly deps: VmRuntimeDeps) {
|
||||
this.cli = new LimaCli({
|
||||
limactlPath: deps.limactlPath,
|
||||
limaHome: deps.limaHome,
|
||||
sshPath: deps.sshPath,
|
||||
})
|
||||
this.readinessTimeoutMs = deps.readinessTimeoutMs ?? 60_000
|
||||
this.readinessPollMs = deps.readinessPollMs ?? 500
|
||||
}
|
||||
|
||||
async ensureReady(onLog?: LogFn): Promise<void> {
|
||||
const started = Date.now()
|
||||
logger.info(VM_TELEMETRY_EVENTS.ensureReadyStart, {
|
||||
limaHome: this.deps.limaHome,
|
||||
browserosRoot: this.deps.browserosRoot,
|
||||
templatePath: this.deps.templatePath,
|
||||
limactlPath: this.deps.limactlPath,
|
||||
})
|
||||
|
||||
await this.ensureCacheAvailable()
|
||||
const cached = await readCachedManifest(this.deps.browserosRoot)
|
||||
const installed = await readInstalledManifest(this.deps.browserosRoot)
|
||||
const versionComparison = compareVersions(installed, cached)
|
||||
logger.debug(VM_TELEMETRY_EVENTS.manifestCompared, {
|
||||
versionComparison,
|
||||
installedUpdatedAt: installed?.updatedAt ?? null,
|
||||
cachedUpdatedAt: cached.updatedAt,
|
||||
})
|
||||
|
||||
const vms = await this.cli.list()
|
||||
const existing = vms.find((vm) => vm.name === VM_NAME)
|
||||
let shouldWriteInstalledManifest =
|
||||
!existing || versionComparison === 'fresh' || versionComparison === 'same'
|
||||
|
||||
let branch = !existing
|
||||
? 'provision-fresh'
|
||||
: existing.status !== 'Running'
|
||||
? 'start-existing'
|
||||
: versionComparison === 'upgrade'
|
||||
? 'running-upgrade-warn'
|
||||
: versionComparison === 'downgrade'
|
||||
? 'running-downgrade-warn'
|
||||
: 'running-same'
|
||||
logger.info(VM_TELEMETRY_EVENTS.ensureReadyBranch, {
|
||||
branch,
|
||||
existingStatus: existing?.status ?? null,
|
||||
versionComparison,
|
||||
})
|
||||
|
||||
if (!existing) {
|
||||
await this.provisionFresh(onLog)
|
||||
} else {
|
||||
if (existing.status !== 'Running') {
|
||||
onLog?.('Starting BrowserOS VM...')
|
||||
await this.cli.start(VM_NAME)
|
||||
}
|
||||
if (
|
||||
!(await this.isReady()) &&
|
||||
(await this.needsContainerdReprovision())
|
||||
) {
|
||||
branch = 'recreate-legacy-runtime'
|
||||
shouldWriteInstalledManifest = true
|
||||
await this.recreateForContainerd(onLog)
|
||||
} else if (versionComparison === 'upgrade') {
|
||||
logger.warn(VM_TELEMETRY_EVENTS.upgradeDetected, {
|
||||
from: installed?.updatedAt ?? null,
|
||||
to: cached.updatedAt,
|
||||
})
|
||||
} else if (versionComparison === 'downgrade') {
|
||||
logger.warn(VM_TELEMETRY_EVENTS.downgradeDetected, {
|
||||
from: installed?.updatedAt ?? null,
|
||||
to: cached.updatedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await this.waitForRootlessNerdctl(this.readinessTimeoutMs)
|
||||
if (shouldWriteInstalledManifest) {
|
||||
await writeInstalledManifest(cached, this.deps.browserosRoot)
|
||||
logger.debug(VM_TELEMETRY_EVENTS.manifestWritten, {
|
||||
updatedAt: cached.updatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
logger.info(VM_TELEMETRY_EVENTS.ensureReadyOk, {
|
||||
durationMs: Date.now() - started,
|
||||
branch,
|
||||
})
|
||||
}
|
||||
|
||||
async stopVm(): Promise<void> {
|
||||
try {
|
||||
await this.cli.stop(VM_NAME)
|
||||
} catch (error) {
|
||||
if (error instanceof LimaCommandError && isAlreadyStopped(error.stderr)) {
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async runCommand(
|
||||
args: string[],
|
||||
opts?: { onOutput?: LogFn },
|
||||
): Promise<number> {
|
||||
return this.cli.shell(VM_NAME, args, {
|
||||
onStdout: opts?.onOutput,
|
||||
onStderr: opts?.onOutput,
|
||||
})
|
||||
}
|
||||
|
||||
async reset(_reason: string): Promise<never> {
|
||||
throw notImplemented('VmRuntime.reset')
|
||||
}
|
||||
|
||||
async performUpgrade(): Promise<never> {
|
||||
throw notImplemented('VmRuntime.performUpgrade')
|
||||
}
|
||||
|
||||
async getDefaultGateway(): Promise<string> {
|
||||
if (this.defaultGateway) return this.defaultGateway
|
||||
|
||||
const lines: string[] = []
|
||||
const exitCode = await this.runCommand(
|
||||
['ip', '-4', 'route', 'show', 'default'],
|
||||
{
|
||||
onOutput: (line) => lines.push(line),
|
||||
},
|
||||
)
|
||||
if (exitCode !== 0) {
|
||||
throw new VmNotReadyError(
|
||||
`failed to resolve VM default gateway; ip route exited ${exitCode}`,
|
||||
)
|
||||
}
|
||||
|
||||
const gateway = parseDefaultGateway(lines.join('\n'))
|
||||
if (!gateway) {
|
||||
throw new VmNotReadyError('failed to resolve VM default gateway')
|
||||
}
|
||||
this.defaultGateway = gateway
|
||||
return gateway
|
||||
}
|
||||
|
||||
async isReady(): Promise<boolean> {
|
||||
return this.isRootlessNerdctlReady()
|
||||
}
|
||||
|
||||
getLimactlPath(): string {
|
||||
return this.deps.limactlPath
|
||||
}
|
||||
|
||||
private async provisionFresh(onLog?: LogFn): Promise<void> {
|
||||
this.defaultGateway = null
|
||||
const yaml = await this.buildLimaYaml()
|
||||
const yamlPath = join(this.deps.limaHome, `${VM_NAME}.yaml`)
|
||||
await mkdir(dirname(yamlPath), { recursive: true })
|
||||
await writeFile(yamlPath, yaml)
|
||||
logger.info(VM_TELEMETRY_EVENTS.provisionYamlWrite, {
|
||||
yamlPath,
|
||||
yamlBytes: yaml.length,
|
||||
templatePath: this.deps.templatePath,
|
||||
})
|
||||
|
||||
onLog?.('Creating BrowserOS VM...')
|
||||
logger.info(VM_TELEMETRY_EVENTS.provisionCreateStart, { yamlPath })
|
||||
const createStarted = Date.now()
|
||||
await this.cli.create(VM_NAME, yamlPath)
|
||||
logger.info(VM_TELEMETRY_EVENTS.provisionCreateOk, {
|
||||
durationMs: Date.now() - createStarted,
|
||||
})
|
||||
|
||||
onLog?.('Starting BrowserOS VM...')
|
||||
logger.info(VM_TELEMETRY_EVENTS.provisionStartBegin, {})
|
||||
const startStarted = Date.now()
|
||||
await this.cli.start(VM_NAME)
|
||||
logger.info(VM_TELEMETRY_EVENTS.provisionStartOk, {
|
||||
durationMs: Date.now() - startStarted,
|
||||
})
|
||||
}
|
||||
|
||||
private async ensureCacheAvailable(): Promise<void> {
|
||||
if (this.deps.ensureCacheAvailable) {
|
||||
await this.deps.ensureCacheAvailable()
|
||||
return
|
||||
}
|
||||
await ensureVmCacheAvailable({ browserosRoot: this.deps.browserosRoot })
|
||||
}
|
||||
|
||||
private async recreateForContainerd(onLog?: LogFn): Promise<void> {
|
||||
onLog?.('Recreating BrowserOS VM for containerd runtime...')
|
||||
try {
|
||||
await this.cli.stop(VM_NAME)
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof LimaCommandError) ||
|
||||
!isAlreadyStopped(error.stderr)
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
await this.cli.delete(VM_NAME)
|
||||
await this.provisionFresh(onLog)
|
||||
}
|
||||
|
||||
private async needsContainerdReprovision(): Promise<boolean> {
|
||||
const lines: string[] = []
|
||||
try {
|
||||
const exitCode = await this.runCommand(
|
||||
['sh', '-lc', 'cat /etc/browseros-vm-version 2>/dev/null || true'],
|
||||
{ onOutput: (line) => lines.push(line) },
|
||||
)
|
||||
if (exitCode !== 0) return false
|
||||
} catch (error) {
|
||||
logger.warn('Failed to inspect BrowserOS VM runtime marker', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
return !lines.some((line) => line.trim() === ROOTLESS_CONTAINERD_MARKER)
|
||||
}
|
||||
|
||||
private async buildLimaYaml(): Promise<string> {
|
||||
if (!this.deps.templatePath) {
|
||||
throw new Error(
|
||||
'BrowserOS VM Lima template path is missing; configure VmRuntime with resourcesDir',
|
||||
)
|
||||
}
|
||||
|
||||
return renderLimaTemplate(await readFile(this.deps.templatePath, 'utf8'), {
|
||||
vmStateDir: getVmStateDir(this.deps.browserosRoot),
|
||||
imageCacheDir: getImageCacheDir(this.deps.browserosRoot),
|
||||
})
|
||||
}
|
||||
|
||||
private async waitForRootlessNerdctl(timeoutMs: number): Promise<void> {
|
||||
const started = Date.now()
|
||||
const deadline = started + timeoutMs
|
||||
logger.info(VM_TELEMETRY_EVENTS.nerdctlWaitStart, {
|
||||
timeoutMs,
|
||||
pollMs: this.readinessPollMs,
|
||||
})
|
||||
let pollCount = 0
|
||||
while (Date.now() < deadline) {
|
||||
pollCount += 1
|
||||
if (await this.isReady()) {
|
||||
logger.info(VM_TELEMETRY_EVENTS.nerdctlWaitOk, {
|
||||
pollCount,
|
||||
waitMs: Date.now() - started,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (pollCount === 1 || pollCount % 10 === 0) {
|
||||
logger.debug(VM_TELEMETRY_EVENTS.nerdctlWaitPoll, {
|
||||
pollCount,
|
||||
elapsedMs: Date.now() - started,
|
||||
})
|
||||
}
|
||||
await Bun.sleep(this.readinessPollMs)
|
||||
}
|
||||
logger.error(VM_TELEMETRY_EVENTS.nerdctlWaitTimeout, {
|
||||
timeoutMs,
|
||||
pollCount,
|
||||
})
|
||||
throw new VmNotReadyError('rootless nerdctl never became ready')
|
||||
}
|
||||
|
||||
private async isRootlessNerdctlReady(): Promise<boolean> {
|
||||
try {
|
||||
return (await this.runCommand(['nerdctl', 'info'])) === 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function notImplemented(feature: string): VmError {
|
||||
return new VmError(
|
||||
`${feature} is not implemented yet - see WS4 follow-up plan`,
|
||||
)
|
||||
}
|
||||
|
||||
function isAlreadyStopped(stderr: string): boolean {
|
||||
const lower = stderr.toLowerCase()
|
||||
return (
|
||||
lower.includes('not running') ||
|
||||
lower.includes('already stopped') ||
|
||||
lower.includes('not found')
|
||||
)
|
||||
}
|
||||
|
||||
function parseDefaultGateway(output: string): string | null {
|
||||
return output.match(/\bdefault\s+via\s+(\d+\.\d+\.\d+\.\d+)\b/)?.[1] ?? null
|
||||
}
|
||||
@@ -13,11 +13,7 @@ import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { EXIT_CODES } from '@browseros/shared/constants/exit-codes'
|
||||
import { createHttpServer } from './api/server'
|
||||
import {
|
||||
configureOpenClawService,
|
||||
configureVmRuntime,
|
||||
getOpenClawService,
|
||||
} from './api/services/openclaw/openclaw-service'
|
||||
import { getOpenClawService } from './api/services/openclaw/openclaw-service'
|
||||
import { CdpBackend } from './browser/backends/cdp'
|
||||
import { Browser } from './browser/browser'
|
||||
import type { ServerConfig } from './config'
|
||||
@@ -35,7 +31,6 @@ import { metrics } from './lib/metrics'
|
||||
import { isPortInUseError } from './lib/port-binding'
|
||||
import { Sentry } from './lib/sentry'
|
||||
import { seedSoulTemplate } from './lib/soul'
|
||||
import { prefetchVmCache } from './lib/vm/cache-sync'
|
||||
import { migrateBuiltinSkills } from './skills/migrate'
|
||||
import {
|
||||
startSkillSync,
|
||||
@@ -60,8 +55,6 @@ export class Application {
|
||||
resourcesDir: path.resolve(this.config.resourcesDir),
|
||||
})
|
||||
|
||||
const resourcesDir = path.resolve(this.config.resourcesDir)
|
||||
configureVmRuntime({ resourcesDir, vmCache: this.vmCacheConfig() })
|
||||
await this.initCoreServices()
|
||||
|
||||
if (!this.config.cdpPort) {
|
||||
@@ -126,11 +119,7 @@ export class Application {
|
||||
this.logStartupSummary()
|
||||
startSkillSync()
|
||||
|
||||
configureOpenClawService({
|
||||
browserosServerPort: this.config.serverPort,
|
||||
resourcesDir,
|
||||
vmCache: this.vmCacheConfig(),
|
||||
})
|
||||
getOpenClawService(this.config.serverPort)
|
||||
.tryAutoStart()
|
||||
.catch((err) =>
|
||||
logger.warn('OpenClaw auto-start failed', {
|
||||
@@ -163,7 +152,6 @@ export class Application {
|
||||
private async initCoreServices(): Promise<void> {
|
||||
this.configureLogDirectory()
|
||||
await ensureBrowserosDir()
|
||||
this.startVmCachePrefetch()
|
||||
await cleanOldSessions()
|
||||
await seedSoulTemplate()
|
||||
await migrateBuiltinSkills()
|
||||
@@ -212,25 +200,6 @@ export class Application {
|
||||
})
|
||||
}
|
||||
|
||||
private startVmCachePrefetch(): void {
|
||||
if (!this.config.vmCachePrefetch) return
|
||||
void prefetchVmCache({
|
||||
manifestUrl: this.config.vmCacheManifestUrl,
|
||||
}).catch((error) => {
|
||||
logger.warn('BrowserOS VM cache prefetch failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private vmCacheConfig(): {
|
||||
manifestUrl: string
|
||||
} {
|
||||
return {
|
||||
manifestUrl: this.config.vmCacheManifestUrl,
|
||||
}
|
||||
}
|
||||
|
||||
private configureLogDirectory(): void {
|
||||
const logDir = this.config.executionDir
|
||||
const resolvedDir = path.isAbsolute(logDir)
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import type {
|
||||
JudgeAuditEnvelope,
|
||||
MonitoringFinalization,
|
||||
MonitoringSessionContext,
|
||||
MonitoringToolCallRecord,
|
||||
} from './types'
|
||||
|
||||
export function buildJudgeAuditEnvelope(input: {
|
||||
context: MonitoringSessionContext
|
||||
toolCalls: MonitoringToolCallRecord[]
|
||||
finalization: MonitoringFinalization | null
|
||||
}): JudgeAuditEnvelope {
|
||||
const envelope: JudgeAuditEnvelope = {
|
||||
run: input.context,
|
||||
toolCalls: input.toolCalls,
|
||||
}
|
||||
|
||||
if (input.finalization) {
|
||||
envelope.finalization = input.finalization
|
||||
}
|
||||
|
||||
return envelope
|
||||
}
|
||||
@@ -1,557 +0,0 @@
|
||||
import type { MonitoringToolCallRecord } from '../types'
|
||||
import type {
|
||||
LazyMonitoringJudgeInput,
|
||||
LazyMonitoringJudgment,
|
||||
LazyMonitoringPolicyDimension,
|
||||
LazyMonitoringVerdict,
|
||||
} from './types'
|
||||
|
||||
const DEFAULT_OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'
|
||||
const DEFAULT_OPENROUTER_MODEL = 'openai/gpt-5.4-mini'
|
||||
const DEFAULT_APP_NAME = 'BrowserOS Lazy Monitoring Judge'
|
||||
const DEFAULT_TIMEOUT_MS = 10_000
|
||||
const MAX_STRING_LENGTH = 1_200
|
||||
const MAX_ARRAY_ITEMS = 8
|
||||
const MAX_OBJECT_KEYS = 20
|
||||
const ALLOWED_DIMENSIONS = new Set<LazyMonitoringPolicyDimension>([
|
||||
'communication_risk',
|
||||
'data_access',
|
||||
'destructive_action',
|
||||
'scope_mismatch',
|
||||
'unexpected_side_effect',
|
||||
])
|
||||
const ALLOWED_VERDICTS = new Set<LazyMonitoringVerdict>([
|
||||
'safe',
|
||||
'needs_review',
|
||||
'suspicious',
|
||||
'unsafe',
|
||||
])
|
||||
|
||||
export class LazyMonitoringJudgeError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options)
|
||||
this.name = 'LazyMonitoringJudgeError'
|
||||
}
|
||||
}
|
||||
|
||||
export interface LazyMonitoringJudgeConfig {
|
||||
provider: 'openrouter' | 'openai-compatible'
|
||||
model: string
|
||||
baseUrl: string
|
||||
apiKey?: string
|
||||
timeoutMs: number
|
||||
siteUrl?: string
|
||||
appName?: string
|
||||
}
|
||||
|
||||
export function resolveLazyMonitoringJudgeConfig(): LazyMonitoringJudgeConfig | null {
|
||||
if (process.env.BROWSEROS_LAZY_MONITORING_JUDGE_DISABLED === 'true') {
|
||||
return null
|
||||
}
|
||||
|
||||
const provider =
|
||||
process.env.BROWSEROS_LAZY_MONITORING_JUDGE_PROVIDER === 'openai-compatible'
|
||||
? 'openai-compatible'
|
||||
: 'openrouter'
|
||||
const model =
|
||||
process.env.BROWSEROS_LAZY_MONITORING_JUDGE_MODEL ??
|
||||
DEFAULT_OPENROUTER_MODEL
|
||||
const timeoutMs = Number.parseInt(
|
||||
process.env.BROWSEROS_LAZY_MONITORING_JUDGE_TIMEOUT_MS ?? '',
|
||||
10,
|
||||
)
|
||||
const config: LazyMonitoringJudgeConfig = {
|
||||
provider,
|
||||
model,
|
||||
baseUrl:
|
||||
process.env.BROWSEROS_LAZY_MONITORING_JUDGE_BASE_URL ??
|
||||
DEFAULT_OPENROUTER_BASE_URL,
|
||||
apiKey:
|
||||
process.env.BROWSEROS_LAZY_MONITORING_JUDGE_API_KEY ??
|
||||
(provider === 'openrouter' ? process.env.OPENROUTER_API_KEY : undefined),
|
||||
timeoutMs:
|
||||
Number.isFinite(timeoutMs) && timeoutMs > 0
|
||||
? timeoutMs
|
||||
: DEFAULT_TIMEOUT_MS,
|
||||
siteUrl: process.env.BROWSEROS_LAZY_MONITORING_JUDGE_SITE_URL,
|
||||
appName:
|
||||
process.env.BROWSEROS_LAZY_MONITORING_JUDGE_APP_NAME ?? DEFAULT_APP_NAME,
|
||||
}
|
||||
|
||||
if (!config.model.trim()) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (provider === 'openrouter' && !config.apiKey?.trim()) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (provider === 'openai-compatible' && !config.baseUrl.trim()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
export function getRequiredLazyMonitoringJudgeConfig(): LazyMonitoringJudgeConfig {
|
||||
const config = resolveLazyMonitoringJudgeConfig()
|
||||
if (!config) {
|
||||
throw new LazyMonitoringJudgeError(
|
||||
'lazy monitoring judge is not configured; set BROWSEROS_LAZY_MONITORING_JUDGE_MODEL and OPENROUTER_API_KEY or BROWSEROS_LAZY_MONITORING_JUDGE_API_KEY',
|
||||
)
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
function truncateString(value: string): string {
|
||||
if (value.length <= MAX_STRING_LENGTH) {
|
||||
return value
|
||||
}
|
||||
return `${value.slice(0, MAX_STRING_LENGTH)}... (+${value.length - MAX_STRING_LENGTH} chars)`
|
||||
}
|
||||
|
||||
function sanitizeForPrompt(value: unknown, depth = 0): unknown {
|
||||
if (typeof value === 'string') {
|
||||
return truncateString(value)
|
||||
}
|
||||
|
||||
if (
|
||||
typeof value === 'number' ||
|
||||
typeof value === 'boolean' ||
|
||||
value === null ||
|
||||
value === undefined
|
||||
) {
|
||||
return value
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.slice(0, MAX_ARRAY_ITEMS)
|
||||
.map((item) => sanitizeForPrompt(item, depth + 1))
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
if (depth >= 4) {
|
||||
return '[truncated]'
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.slice(0, MAX_OBJECT_KEYS)
|
||||
.map(([key, nested]) => [key, sanitizeForPrompt(nested, depth + 1)]),
|
||||
)
|
||||
}
|
||||
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function extractMessageText(payload: unknown): string {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
throw new LazyMonitoringJudgeError('judge response was not an object')
|
||||
}
|
||||
|
||||
const choices = (payload as { choices?: unknown }).choices
|
||||
if (!Array.isArray(choices) || choices.length === 0) {
|
||||
throw new LazyMonitoringJudgeError(
|
||||
'judge response did not include any choices',
|
||||
)
|
||||
}
|
||||
|
||||
const message = choices[0]
|
||||
if (!message || typeof message !== 'object') {
|
||||
throw new LazyMonitoringJudgeError('judge choice was malformed')
|
||||
}
|
||||
|
||||
const content = (message as { message?: { content?: unknown } }).message
|
||||
?.content
|
||||
|
||||
if (typeof content === 'string') {
|
||||
return content.trim()
|
||||
}
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
const text = content
|
||||
.flatMap((part) =>
|
||||
part && typeof part === 'object' && typeof part.text === 'string'
|
||||
? [part.text]
|
||||
: [],
|
||||
)
|
||||
.join('\n')
|
||||
.trim()
|
||||
|
||||
if (text) {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
throw new LazyMonitoringJudgeError(
|
||||
'judge response did not contain text content',
|
||||
)
|
||||
}
|
||||
|
||||
function extractJsonObject(text: string): Record<string, unknown> {
|
||||
try {
|
||||
const parsed = JSON.parse(text)
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
} catch {
|
||||
// Fall through to brace extraction.
|
||||
}
|
||||
|
||||
const start = text.indexOf('{')
|
||||
const end = text.lastIndexOf('}')
|
||||
if (start === -1 || end === -1 || end <= start) {
|
||||
throw new LazyMonitoringJudgeError(
|
||||
'judge response did not contain a JSON object',
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(text.slice(start, end + 1))
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
} catch {
|
||||
throw new LazyMonitoringJudgeError('judge response JSON was malformed')
|
||||
}
|
||||
|
||||
throw new LazyMonitoringJudgeError('judge response JSON must be an object')
|
||||
}
|
||||
|
||||
function normalizeDimensions(value: unknown): LazyMonitoringPolicyDimension[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const normalized = value.filter(
|
||||
(dimension): dimension is LazyMonitoringPolicyDimension =>
|
||||
typeof dimension === 'string' &&
|
||||
ALLOWED_DIMENSIONS.has(dimension as LazyMonitoringPolicyDimension),
|
||||
)
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
function getPreviousUserPrompt(input: LazyMonitoringJudgeInput): string | null {
|
||||
for (let index = input.run.chatHistory.length - 1; index >= 0; index -= 1) {
|
||||
const turn = input.run.chatHistory[index]
|
||||
if (turn?.role === 'user' && typeof turn.content === 'string') {
|
||||
return turn.content
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const SNAPSHOT_ELEMENT_ARG_KEYS = [
|
||||
'element',
|
||||
'sourceElement',
|
||||
'targetElement',
|
||||
] as const
|
||||
const SNAPSHOT_LINE_PATTERN = /^\[(\d+)\]\s+/
|
||||
|
||||
function getTextContent(contentItem: unknown): string | null {
|
||||
if (!contentItem || typeof contentItem !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const record = contentItem as { type?: unknown; text?: unknown }
|
||||
|
||||
return record.type === 'text' && typeof record.text === 'string'
|
||||
? record.text
|
||||
: null
|
||||
}
|
||||
|
||||
function collectSnapshotLines(output: unknown): string[] {
|
||||
if (!output || typeof output !== 'object') {
|
||||
return []
|
||||
}
|
||||
|
||||
const lines: string[] = []
|
||||
const record = output as {
|
||||
content?: unknown
|
||||
structuredContent?: { snapshot?: unknown }
|
||||
}
|
||||
|
||||
const snapshot = record.structuredContent?.snapshot
|
||||
if (typeof snapshot === 'string' && snapshot.trim()) {
|
||||
lines.push(...snapshot.split('\n'))
|
||||
}
|
||||
|
||||
if (Array.isArray(record.content)) {
|
||||
for (const item of record.content) {
|
||||
const text = getTextContent(item)
|
||||
if (text?.trim()) {
|
||||
lines.push(...text.split('\n'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => SNAPSHOT_LINE_PATTERN.test(line))
|
||||
}
|
||||
|
||||
function findLatestSnapshotLine(
|
||||
priorToolCalls: LazyMonitoringJudgeInput['priorToolCalls'],
|
||||
elementId: number,
|
||||
): {
|
||||
toolCallId: string
|
||||
toolName: string
|
||||
line: string
|
||||
} | null {
|
||||
for (
|
||||
let callIndex = priorToolCalls.length - 1;
|
||||
callIndex >= 0;
|
||||
callIndex -= 1
|
||||
) {
|
||||
const toolCall = priorToolCalls[callIndex]
|
||||
if (!toolCall) {
|
||||
continue
|
||||
}
|
||||
|
||||
const lines = collectSnapshotLines(toolCall.output)
|
||||
for (let lineIndex = lines.length - 1; lineIndex >= 0; lineIndex -= 1) {
|
||||
const line = lines[lineIndex]
|
||||
const match = line?.match(SNAPSHOT_LINE_PATTERN)
|
||||
if (match && Number(match[1]) === elementId) {
|
||||
return {
|
||||
toolCallId: toolCall.toolCallId,
|
||||
toolName: toolCall.toolName,
|
||||
line,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function enrichCurrentToolArgsWithSnapshotContext(
|
||||
input: LazyMonitoringJudgeInput,
|
||||
): unknown {
|
||||
const args = input.currentToolCall.args
|
||||
if (!args || typeof args !== 'object' || Array.isArray(args)) {
|
||||
return args
|
||||
}
|
||||
|
||||
const argRecord = args as Record<string, unknown>
|
||||
const lazyMonitoringContext: Record<string, unknown> = {}
|
||||
|
||||
for (const key of SNAPSHOT_ELEMENT_ARG_KEYS) {
|
||||
const elementId = argRecord[key]
|
||||
if (typeof elementId !== 'number') {
|
||||
continue
|
||||
}
|
||||
|
||||
const match = findLatestSnapshotLine(input.priorToolCalls, elementId)
|
||||
if (!match) {
|
||||
continue
|
||||
}
|
||||
|
||||
lazyMonitoringContext[key] = {
|
||||
id: elementId,
|
||||
lastSnapshotLine: match.line,
|
||||
matchedFromToolCallId: match.toolCallId,
|
||||
matchedFromToolName: match.toolName,
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(lazyMonitoringContext).length === 0) {
|
||||
return args
|
||||
}
|
||||
|
||||
return {
|
||||
...argRecord,
|
||||
lazyMonitoringContext,
|
||||
}
|
||||
}
|
||||
|
||||
function buildToolCallPayload(
|
||||
toolCall: MonitoringToolCallRecord,
|
||||
args = toolCall.args,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
toolCallId: toolCall.toolCallId,
|
||||
toolName: toolCall.toolName,
|
||||
toolDescription: toolCall.toolDescription,
|
||||
source: toolCall.source,
|
||||
args,
|
||||
output: toolCall.output,
|
||||
error: toolCall.error,
|
||||
}
|
||||
}
|
||||
|
||||
function buildPayload(
|
||||
input: LazyMonitoringJudgeInput,
|
||||
): Record<string, unknown> {
|
||||
const previousToolCall = input.priorToolCalls.at(-1)
|
||||
|
||||
return {
|
||||
currentUserPrompt: sanitizeForPrompt(input.run.originalPrompt),
|
||||
previousUserPrompt: sanitizeForPrompt(getPreviousUserPrompt(input)),
|
||||
previousToolCall: sanitizeForPrompt(
|
||||
previousToolCall ? buildToolCallPayload(previousToolCall) : null,
|
||||
),
|
||||
currentToolCall: sanitizeForPrompt(
|
||||
buildToolCallPayload(
|
||||
input.currentToolCall,
|
||||
enrichCurrentToolArgsWithSnapshotContext(input),
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export class RemoteLazyMonitoringJudgeClient {
|
||||
constructor(private readonly config: LazyMonitoringJudgeConfig) {}
|
||||
|
||||
async judge(
|
||||
input: LazyMonitoringJudgeInput,
|
||||
): Promise<LazyMonitoringJudgment> {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(
|
||||
() =>
|
||||
controller.abort(
|
||||
`judge request timed out after ${this.config.timeoutMs}ms`,
|
||||
),
|
||||
this.config.timeoutMs,
|
||||
)
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${this.config.baseUrl.replace(/\/$/, '')}/chat/completions`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: this.buildHeaders(),
|
||||
body: JSON.stringify({
|
||||
model: this.config.model,
|
||||
temperature: 0,
|
||||
max_tokens: 160,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'You are a passive judge for BrowserOS lazy monitoring. Review the proposed tool call in context using only the supplied evidence. Judge whether the tool call aligns with user intentions and isnt destructive. Do not be overly cautious and make inferences w.r.t the user prompt to make your judgement. Do not worry about any other aspect. Return strict JSON with keys verdict, summary, and policyDimensions. verdict must be one of safe, needs_review, suspicious, unsafe.',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: JSON.stringify(buildPayload(input), null, 2),
|
||||
},
|
||||
],
|
||||
response_format: {
|
||||
type: 'json_schema',
|
||||
json_schema: {
|
||||
name: 'lazy_monitoring_judgment',
|
||||
schema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
verdict: {
|
||||
type: 'string',
|
||||
enum: ['safe', 'needs_review', 'suspicious', 'unsafe'],
|
||||
},
|
||||
summary: { type: 'string' },
|
||||
policyDimensions: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
'scope_mismatch',
|
||||
'unexpected_side_effect',
|
||||
'destructive_action',
|
||||
'communication_risk',
|
||||
'data_access',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['verdict', 'summary', 'policyDimensions'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
signal: controller.signal,
|
||||
},
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text()
|
||||
throw new LazyMonitoringJudgeError(
|
||||
`judge request failed with HTTP ${response.status}: ${detail}`,
|
||||
)
|
||||
}
|
||||
|
||||
const text = extractMessageText(await response.json())
|
||||
const verdict = extractJsonObject(text)
|
||||
const parsedVerdict = verdict.verdict
|
||||
const summary = verdict.summary
|
||||
const policyDimensions = normalizeDimensions(verdict.policyDimensions)
|
||||
|
||||
if (
|
||||
typeof parsedVerdict !== 'string' ||
|
||||
!ALLOWED_VERDICTS.has(parsedVerdict as LazyMonitoringVerdict)
|
||||
) {
|
||||
throw new LazyMonitoringJudgeError('judge verdict was invalid')
|
||||
}
|
||||
|
||||
if (typeof summary !== 'string' || !summary.trim()) {
|
||||
throw new LazyMonitoringJudgeError('judge summary was empty')
|
||||
}
|
||||
|
||||
return {
|
||||
monitoringSessionId: input.run.monitoringSessionId,
|
||||
agentId: input.run.agentId,
|
||||
toolCallId: input.currentToolCall.toolCallId,
|
||||
toolName: input.currentToolCall.toolName,
|
||||
verdict: parsedVerdict as LazyMonitoringVerdict,
|
||||
summary: summary.trim(),
|
||||
destructive: policyDimensions.includes('destructive_action'),
|
||||
shouldInterrupt:
|
||||
parsedVerdict === 'suspicious' || parsedVerdict === 'unsafe',
|
||||
mode: 'llm',
|
||||
categories: [],
|
||||
matchedIntentCategories: [],
|
||||
policyDimensions,
|
||||
policyVersion: 'lazy-monitoring-judge/v1',
|
||||
model: this.config.model,
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof LazyMonitoringJudgeError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
const abortReason = controller.signal.reason
|
||||
const reasonDetail =
|
||||
typeof abortReason === 'string'
|
||||
? abortReason
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: 'judge request failed'
|
||||
|
||||
throw new LazyMonitoringJudgeError(reasonDetail, { cause: error })
|
||||
} finally {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
private buildHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
if (this.config.apiKey) {
|
||||
headers.Authorization = `Bearer ${this.config.apiKey}`
|
||||
}
|
||||
|
||||
if (this.config.provider === 'openrouter') {
|
||||
if (this.config.siteUrl) {
|
||||
headers['HTTP-Referer'] = this.config.siteUrl
|
||||
}
|
||||
headers['X-Title'] = this.config.appName ?? DEFAULT_APP_NAME
|
||||
}
|
||||
|
||||
return headers
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import {
|
||||
LazyMonitoringJudgeError,
|
||||
RemoteLazyMonitoringJudgeClient,
|
||||
resolveLazyMonitoringJudgeConfig,
|
||||
} from './llm-judge'
|
||||
import type { LazyMonitoringJudgeInput, LazyMonitoringJudgment } from './types'
|
||||
|
||||
export interface LazyMonitoringJudgeClient {
|
||||
judge(input: LazyMonitoringJudgeInput): Promise<LazyMonitoringJudgment>
|
||||
}
|
||||
|
||||
export class LazyMonitoringJudgeService {
|
||||
constructor(private readonly client?: LazyMonitoringJudgeClient) {}
|
||||
|
||||
async evaluate(
|
||||
input: LazyMonitoringJudgeInput,
|
||||
): Promise<LazyMonitoringJudgment> {
|
||||
if (!this.client) {
|
||||
throw new LazyMonitoringJudgeError(
|
||||
'lazy monitoring judge is not configured',
|
||||
)
|
||||
}
|
||||
|
||||
return this.client.judge(input)
|
||||
}
|
||||
}
|
||||
|
||||
export function createLazyMonitoringJudgeService(): LazyMonitoringJudgeService {
|
||||
const config = resolveLazyMonitoringJudgeConfig()
|
||||
return new LazyMonitoringJudgeService(
|
||||
config ? new RemoteLazyMonitoringJudgeClient(config) : undefined,
|
||||
)
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import type {
|
||||
MonitoringSessionContext,
|
||||
MonitoringToolCallRecord,
|
||||
} from '../types'
|
||||
|
||||
export type LazyMonitoringVerdict =
|
||||
| 'safe'
|
||||
| 'needs_review'
|
||||
| 'suspicious'
|
||||
| 'unsafe'
|
||||
|
||||
export type LazyMonitoringReviewMode = 'llm'
|
||||
|
||||
export type LazyMonitoringPolicyDimension =
|
||||
| 'scope_mismatch'
|
||||
| 'unexpected_side_effect'
|
||||
| 'destructive_action'
|
||||
| 'communication_risk'
|
||||
| 'data_access'
|
||||
|
||||
export interface LazyMonitoringJudgeInput {
|
||||
run: MonitoringSessionContext
|
||||
priorToolCalls: MonitoringToolCallRecord[]
|
||||
currentToolCall: MonitoringToolCallRecord
|
||||
}
|
||||
|
||||
export interface LazyMonitoringJudgment {
|
||||
monitoringSessionId: string
|
||||
agentId: string
|
||||
toolCallId: string
|
||||
toolName: string
|
||||
verdict: LazyMonitoringVerdict
|
||||
summary: string
|
||||
destructive: boolean
|
||||
shouldInterrupt: boolean
|
||||
mode: LazyMonitoringReviewMode
|
||||
categories: string[]
|
||||
matchedIntentCategories: string[]
|
||||
policyDimensions: LazyMonitoringPolicyDimension[]
|
||||
policyVersion: string
|
||||
model?: string
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { logger } from '../lib/logger'
|
||||
import type { MonitoringToolEndInput, MonitoringToolStartInput } from './types'
|
||||
|
||||
export interface ToolExecutionObserver {
|
||||
onToolStart(input: MonitoringToolStartInput): Promise<void>
|
||||
onToolEnd(input: MonitoringToolEndInput): Promise<void>
|
||||
}
|
||||
|
||||
export function swallowMonitoringError(
|
||||
operation: string,
|
||||
error: unknown,
|
||||
metadata: Record<string, unknown>,
|
||||
): void {
|
||||
logger.warn(`Lazy monitoring ${operation} failed`, {
|
||||
...metadata,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
|
||||
export function buildMonitoringToolOutput(output: {
|
||||
content?: unknown
|
||||
structuredContent?: unknown
|
||||
metadata?: unknown
|
||||
isError?: boolean
|
||||
}): Record<string, unknown> {
|
||||
const sanitizeContentItem = (item: unknown): unknown => {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return item
|
||||
}
|
||||
|
||||
const record = item as {
|
||||
type?: unknown
|
||||
mimeType?: unknown
|
||||
data?: unknown
|
||||
}
|
||||
|
||||
if (
|
||||
record.type === 'image' &&
|
||||
typeof record.mimeType === 'string' &&
|
||||
typeof record.data === 'string'
|
||||
) {
|
||||
return {
|
||||
type: 'image',
|
||||
mimeType: record.mimeType,
|
||||
omitted: true,
|
||||
dataLength: record.data.length,
|
||||
}
|
||||
}
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
return {
|
||||
content: Array.isArray(output.content)
|
||||
? output.content.map((item) => sanitizeContentItem(item))
|
||||
: output.content,
|
||||
structuredContent: output.structuredContent,
|
||||
metadata: output.metadata,
|
||||
isError: output.isError,
|
||||
}
|
||||
}
|
||||
@@ -1,347 +0,0 @@
|
||||
import { buildJudgeAuditEnvelope } from './envelope'
|
||||
import { LazyMonitoringJudgeError } from './judge/llm-judge'
|
||||
import type { LazyMonitoringJudgeService } from './judge/service'
|
||||
import { createLazyMonitoringJudgeService } from './judge/service'
|
||||
import { swallowMonitoringError, type ToolExecutionObserver } from './observer'
|
||||
import { MonitoringSessionRegistry } from './session-registry'
|
||||
import { MonitoringStorage } from './storage'
|
||||
import type {
|
||||
JudgeAuditEnvelope,
|
||||
MonitoringFinalization,
|
||||
MonitoringFinalizeInput,
|
||||
MonitoringRunSummary,
|
||||
MonitoringSessionContext,
|
||||
MonitoringSessionStartInput,
|
||||
MonitoringToolCallRecord,
|
||||
MonitoringToolEndInput,
|
||||
MonitoringToolStartInput,
|
||||
} from './types'
|
||||
|
||||
type ActiveToolCallState = Omit<
|
||||
MonitoringToolCallRecord,
|
||||
'finishedAt' | 'durationMs' | 'error' | 'output'
|
||||
>
|
||||
|
||||
interface MonitoringServiceDeps {
|
||||
storage?: MonitoringStorage
|
||||
registry?: MonitoringSessionRegistry
|
||||
judge?: LazyMonitoringJudgeService
|
||||
}
|
||||
|
||||
export class MonitoringService {
|
||||
private readonly storage: MonitoringStorage
|
||||
private readonly registry: MonitoringSessionRegistry
|
||||
private readonly judge: LazyMonitoringJudgeService
|
||||
private readonly completedToolCallsBySession = new Map<
|
||||
string,
|
||||
MonitoringToolCallRecord[]
|
||||
>()
|
||||
|
||||
constructor(deps: MonitoringServiceDeps = {}) {
|
||||
this.storage = deps.storage ?? new MonitoringStorage()
|
||||
this.registry = deps.registry ?? new MonitoringSessionRegistry()
|
||||
this.judge = deps.judge ?? createLazyMonitoringJudgeService()
|
||||
}
|
||||
|
||||
async startSession(
|
||||
input: MonitoringSessionStartInput,
|
||||
): Promise<MonitoringSessionContext> {
|
||||
const context: MonitoringSessionContext = {
|
||||
monitoringSessionId: crypto.randomUUID(),
|
||||
agentId: input.agentId,
|
||||
sessionKey: input.sessionKey,
|
||||
originalPrompt: input.originalPrompt,
|
||||
chatHistory: input.chatHistory,
|
||||
startedAt: new Date().toISOString(),
|
||||
source: input.source ?? 'openclaw-agent-chat',
|
||||
}
|
||||
|
||||
await this.storage.writeContext(context)
|
||||
this.registry.setActive(
|
||||
context.agentId,
|
||||
context.monitoringSessionId,
|
||||
context.source,
|
||||
)
|
||||
this.completedToolCallsBySession.set(context.monitoringSessionId, [])
|
||||
return context
|
||||
}
|
||||
|
||||
getActiveSessionId(agentId: string): string | undefined {
|
||||
return this.registry.getActive(agentId)
|
||||
}
|
||||
|
||||
resolveSessionForMcpRequest(
|
||||
explicitAgentId?: string,
|
||||
): { agentId: string; monitoringSessionId: string } | undefined {
|
||||
if (explicitAgentId) {
|
||||
const monitoringSessionId = this.registry.getActive(explicitAgentId)
|
||||
return monitoringSessionId
|
||||
? { agentId: explicitAgentId, monitoringSessionId }
|
||||
: undefined
|
||||
}
|
||||
|
||||
return this.registry.resolveForUnattributedToolCalls()
|
||||
}
|
||||
|
||||
clearActiveSession(agentId: string, monitoringSessionId: string): void {
|
||||
this.registry.clearIfMatches(agentId, monitoringSessionId)
|
||||
}
|
||||
|
||||
createObserver(
|
||||
monitoringSessionId: string,
|
||||
agentId: string,
|
||||
): ToolExecutionObserver {
|
||||
const activeToolCalls = new Map<string, ActiveToolCallState>()
|
||||
const completedToolCalls =
|
||||
this.completedToolCallsBySession.get(monitoringSessionId) ?? []
|
||||
this.completedToolCallsBySession.set(
|
||||
monitoringSessionId,
|
||||
completedToolCalls,
|
||||
)
|
||||
const contextPromise = this.storage.readContext(monitoringSessionId)
|
||||
let judgeQueue = Promise.resolve()
|
||||
|
||||
const enqueueJudgeReview = (toolCall: ActiveToolCallState): void => {
|
||||
const priorToolCalls = [...completedToolCalls]
|
||||
|
||||
judgeQueue = judgeQueue
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
const context = await contextPromise
|
||||
if (!context) {
|
||||
return
|
||||
}
|
||||
|
||||
const judgment = await this.judge.evaluate({
|
||||
run: context,
|
||||
priorToolCalls,
|
||||
currentToolCall: toolCall,
|
||||
})
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
type: 'lazy-monitoring-judge',
|
||||
monitoringSessionId,
|
||||
agentId,
|
||||
originalPrompt: context.originalPrompt,
|
||||
toolCallId: judgment.toolCallId,
|
||||
toolName: judgment.toolName,
|
||||
verdict: judgment.verdict,
|
||||
summary: judgment.summary,
|
||||
mode: judgment.mode,
|
||||
destructive: judgment.destructive,
|
||||
categories: judgment.categories,
|
||||
matchedIntentCategories: judgment.matchedIntentCategories,
|
||||
policyDimensions: judgment.policyDimensions,
|
||||
policyVersion: judgment.policyVersion,
|
||||
model: judgment.model,
|
||||
shouldInterrupt: judgment.shouldInterrupt,
|
||||
}),
|
||||
)
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error instanceof LazyMonitoringJudgeError) {
|
||||
const errorPayload: Record<string, unknown> = {
|
||||
type: 'lazy-monitoring-judge-error',
|
||||
monitoringSessionId,
|
||||
agentId,
|
||||
toolCallId: toolCall.toolCallId,
|
||||
toolName: toolCall.toolName,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
}
|
||||
if (error.cause) {
|
||||
const cause = error.cause
|
||||
errorPayload.cause =
|
||||
cause instanceof Error
|
||||
? {
|
||||
message: cause.message,
|
||||
name: cause.name,
|
||||
stack: cause.stack,
|
||||
}
|
||||
: String(cause)
|
||||
}
|
||||
console.error(JSON.stringify(errorPayload))
|
||||
this.storage
|
||||
.appendErrorLog(monitoringSessionId, errorPayload)
|
||||
.catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
swallowMonitoringError('judge review', error, {
|
||||
monitoringSessionId,
|
||||
agentId,
|
||||
toolCallId: toolCall.toolCallId,
|
||||
toolName: toolCall.toolName,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
onToolStart: async (input: MonitoringToolStartInput) => {
|
||||
try {
|
||||
const toolCall: ActiveToolCallState = {
|
||||
monitoringSessionId,
|
||||
agentId,
|
||||
toolCallId: input.toolCallId,
|
||||
toolName: input.toolName,
|
||||
toolDescription: input.toolDescription,
|
||||
source: input.source,
|
||||
args: input.args,
|
||||
startedAt: new Date().toISOString(),
|
||||
}
|
||||
activeToolCalls.set(input.toolCallId, toolCall)
|
||||
enqueueJudgeReview(toolCall)
|
||||
} catch (error) {
|
||||
swallowMonitoringError('tool start recording', error, {
|
||||
monitoringSessionId,
|
||||
agentId,
|
||||
toolCallId: input.toolCallId,
|
||||
toolName: input.toolName,
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
onToolEnd: async (input: MonitoringToolEndInput) => {
|
||||
try {
|
||||
const active = activeToolCalls.get(input.toolCallId)
|
||||
if (!active) return
|
||||
|
||||
const finishedAt = new Date().toISOString()
|
||||
const durationMs = Math.max(
|
||||
0,
|
||||
new Date(finishedAt).getTime() -
|
||||
new Date(active.startedAt).getTime(),
|
||||
)
|
||||
|
||||
const record: MonitoringToolCallRecord = {
|
||||
...active,
|
||||
finishedAt,
|
||||
durationMs,
|
||||
}
|
||||
|
||||
if (input.error) {
|
||||
record.error = input.error
|
||||
}
|
||||
if (input.output !== undefined) {
|
||||
record.output = input.output
|
||||
}
|
||||
|
||||
await this.storage.appendToolCall(record)
|
||||
completedToolCalls.push(record)
|
||||
activeToolCalls.delete(input.toolCallId)
|
||||
} catch (error) {
|
||||
swallowMonitoringError('tool end recording', error, {
|
||||
monitoringSessionId,
|
||||
agentId,
|
||||
toolCallId: input.toolCallId,
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async finalizeSession(
|
||||
input: MonitoringFinalizeInput,
|
||||
): Promise<JudgeAuditEnvelope | null> {
|
||||
const context = await this.storage.readContext(input.monitoringSessionId)
|
||||
if (!context) {
|
||||
return null
|
||||
}
|
||||
|
||||
const finalization: MonitoringFinalization = {
|
||||
monitoringSessionId: input.monitoringSessionId,
|
||||
agentId: input.agentId,
|
||||
sessionKey: input.sessionKey,
|
||||
status: input.status,
|
||||
finalizedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
if (input.finalAssistantMessage) {
|
||||
finalization.finalAssistantMessage = input.finalAssistantMessage
|
||||
}
|
||||
if (input.error) {
|
||||
finalization.error = input.error
|
||||
}
|
||||
|
||||
await this.storage.writeFinalization(finalization)
|
||||
this.registry.clearIfMatches(input.agentId, input.monitoringSessionId)
|
||||
const envelope = await this.buildAndPersistEnvelope(
|
||||
input.monitoringSessionId,
|
||||
)
|
||||
this.completedToolCallsBySession.delete(input.monitoringSessionId)
|
||||
return envelope
|
||||
}
|
||||
|
||||
async getRunEnvelope(runId: string): Promise<JudgeAuditEnvelope | null> {
|
||||
const context = await this.storage.readContext(runId)
|
||||
if (!context) return null
|
||||
|
||||
const toolCalls = await this.storage.readToolCalls(runId)
|
||||
const finalization = await this.storage.readFinalization(runId)
|
||||
|
||||
return buildJudgeAuditEnvelope({
|
||||
context,
|
||||
toolCalls,
|
||||
finalization,
|
||||
})
|
||||
}
|
||||
|
||||
async listRuns(limit = 50): Promise<MonitoringRunSummary[]> {
|
||||
const runIds = (await this.storage.listRunIds()).slice(0, limit)
|
||||
const summaries = await Promise.all(
|
||||
runIds.map(async (runId) => {
|
||||
const context = await this.storage.readContext(runId)
|
||||
if (!context) return null
|
||||
|
||||
const [toolCalls, finalization] = await Promise.all([
|
||||
this.storage.readToolCalls(runId),
|
||||
this.storage.readFinalization(runId),
|
||||
])
|
||||
|
||||
const summary: MonitoringRunSummary = {
|
||||
monitoringSessionId: context.monitoringSessionId,
|
||||
agentId: context.agentId,
|
||||
sessionKey: context.sessionKey,
|
||||
originalPrompt: context.originalPrompt,
|
||||
startedAt: context.startedAt,
|
||||
source: context.source,
|
||||
toolCallCount: toolCalls.length,
|
||||
}
|
||||
|
||||
if (finalization) {
|
||||
summary.finalization = {
|
||||
status: finalization.status,
|
||||
finalizedAt: finalization.finalizedAt,
|
||||
error: finalization.error,
|
||||
}
|
||||
}
|
||||
|
||||
return summary
|
||||
}),
|
||||
)
|
||||
|
||||
return summaries.filter((summary): summary is MonitoringRunSummary =>
|
||||
Boolean(summary),
|
||||
)
|
||||
}
|
||||
|
||||
private async buildAndPersistEnvelope(
|
||||
runId: string,
|
||||
): Promise<JudgeAuditEnvelope | null> {
|
||||
const envelope = await this.getRunEnvelope(runId)
|
||||
if (!envelope) return null
|
||||
|
||||
await this.storage.writeAuditEnvelope(runId, envelope)
|
||||
return envelope
|
||||
}
|
||||
}
|
||||
|
||||
let monitoringService: MonitoringService | null = null
|
||||
|
||||
export function getMonitoringService(): MonitoringService {
|
||||
if (!monitoringService) {
|
||||
monitoringService = new MonitoringService()
|
||||
}
|
||||
return monitoringService
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import type { MonitoringSessionContext } from './types'
|
||||
|
||||
interface ActiveMonitoringSession {
|
||||
monitoringSessionId: string
|
||||
source: MonitoringSessionContext['source']
|
||||
}
|
||||
|
||||
export class MonitoringSessionRegistry {
|
||||
private readonly activeSessionsByAgent = new Map<
|
||||
string,
|
||||
ActiveMonitoringSession
|
||||
>()
|
||||
|
||||
setActive(
|
||||
agentId: string,
|
||||
monitoringSessionId: string,
|
||||
source: MonitoringSessionContext['source'],
|
||||
): void {
|
||||
this.activeSessionsByAgent.set(agentId, { monitoringSessionId, source })
|
||||
}
|
||||
|
||||
getActive(agentId: string): string | undefined {
|
||||
return this.activeSessionsByAgent.get(agentId)?.monitoringSessionId
|
||||
}
|
||||
|
||||
resolveForUnattributedToolCalls():
|
||||
| { agentId: string; monitoringSessionId: string }
|
||||
| undefined {
|
||||
const activeSessions = [...this.activeSessionsByAgent.entries()].flatMap(
|
||||
([agentId, session]) =>
|
||||
session?.monitoringSessionId
|
||||
? [
|
||||
{
|
||||
agentId,
|
||||
monitoringSessionId: session.monitoringSessionId,
|
||||
source: session.source,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
)
|
||||
|
||||
if (activeSessions.length === 1) {
|
||||
const [{ agentId, monitoringSessionId }] = activeSessions
|
||||
return { agentId, monitoringSessionId }
|
||||
}
|
||||
|
||||
const openClawSessions = activeSessions.filter(
|
||||
(session) => session.source === 'openclaw-agent-chat',
|
||||
)
|
||||
|
||||
if (openClawSessions.length === 1) {
|
||||
const [{ agentId, monitoringSessionId }] = openClawSessions
|
||||
return { agentId, monitoringSessionId }
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
clearIfMatches(agentId: string, monitoringSessionId: string): void {
|
||||
if (
|
||||
this.activeSessionsByAgent.get(agentId)?.monitoringSessionId !==
|
||||
monitoringSessionId
|
||||
) {
|
||||
return
|
||||
}
|
||||
this.activeSessionsByAgent.delete(agentId)
|
||||
}
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
import {
|
||||
appendFile,
|
||||
mkdir,
|
||||
readdir,
|
||||
readFile,
|
||||
stat,
|
||||
writeFile,
|
||||
} from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
getLazyMonitoringRunDir,
|
||||
getLazyMonitoringRunsDir,
|
||||
} from '../lib/browseros-dir'
|
||||
import type {
|
||||
MonitoringFinalization,
|
||||
MonitoringSessionContext,
|
||||
MonitoringToolCallRecord,
|
||||
} from './types'
|
||||
|
||||
const CONTEXT_FILE_NAME = 'context.json'
|
||||
const TOOL_CALLS_FILE_NAME = 'tool-calls.jsonl'
|
||||
const ERROR_LOG_FILE_NAME = 'error-log.jsonl'
|
||||
const FINALIZATION_FILE_NAME = 'finalization.json'
|
||||
const AUDIT_ENVELOPE_FILE_NAME = 'audit-envelope.json'
|
||||
const UUID_PATTERN =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
|
||||
export class InvalidMonitoringRunIdError extends Error {
|
||||
constructor(runId: string) {
|
||||
super(`Invalid monitoring run id: ${runId}`)
|
||||
this.name = 'InvalidMonitoringRunIdError'
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidMonitoringRunId(runId: string): boolean {
|
||||
return UUID_PATTERN.test(runId)
|
||||
}
|
||||
|
||||
function assertValidMonitoringRunId(runId: string): void {
|
||||
if (!isValidMonitoringRunId(runId)) {
|
||||
throw new InvalidMonitoringRunIdError(runId)
|
||||
}
|
||||
}
|
||||
|
||||
export class MonitoringStorage {
|
||||
async writeContext(context: MonitoringSessionContext): Promise<void> {
|
||||
await this.ensureRunDir(context.monitoringSessionId)
|
||||
await writeFile(
|
||||
this.getContextPath(context.monitoringSessionId),
|
||||
`${JSON.stringify(context, null, 2)}\n`,
|
||||
)
|
||||
}
|
||||
|
||||
async appendToolCall(record: MonitoringToolCallRecord): Promise<void> {
|
||||
await this.ensureRunDir(record.monitoringSessionId)
|
||||
await appendFile(
|
||||
this.getToolCallsPath(record.monitoringSessionId),
|
||||
`${JSON.stringify(record)}\n`,
|
||||
)
|
||||
}
|
||||
|
||||
async writeFinalization(finalization: MonitoringFinalization): Promise<void> {
|
||||
await this.ensureRunDir(finalization.monitoringSessionId)
|
||||
await writeFile(
|
||||
this.getFinalizationPath(finalization.monitoringSessionId),
|
||||
`${JSON.stringify(finalization, null, 2)}\n`,
|
||||
)
|
||||
}
|
||||
|
||||
async appendErrorLog(
|
||||
runId: string,
|
||||
entry: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
await this.ensureRunDir(runId)
|
||||
await appendFile(
|
||||
this.getErrorLogPath(runId),
|
||||
`${JSON.stringify({ ...entry, timestamp: new Date().toISOString() })}\n`,
|
||||
)
|
||||
}
|
||||
|
||||
async writeAuditEnvelope(runId: string, envelope: unknown): Promise<void> {
|
||||
await this.ensureRunDir(runId)
|
||||
await writeFile(
|
||||
this.getAuditEnvelopePath(runId),
|
||||
`${JSON.stringify(envelope, null, 2)}\n`,
|
||||
)
|
||||
}
|
||||
|
||||
async readContext(runId: string): Promise<MonitoringSessionContext | null> {
|
||||
return this.readJsonFile<MonitoringSessionContext>(
|
||||
this.getContextPath(runId),
|
||||
)
|
||||
}
|
||||
|
||||
async readFinalization(
|
||||
runId: string,
|
||||
): Promise<MonitoringFinalization | null> {
|
||||
return this.readJsonFile<MonitoringFinalization>(
|
||||
this.getFinalizationPath(runId),
|
||||
)
|
||||
}
|
||||
|
||||
async readToolCalls(runId: string): Promise<MonitoringToolCallRecord[]> {
|
||||
try {
|
||||
const content = await readFile(this.getToolCallsPath(runId), 'utf8')
|
||||
return content
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.flatMap((line) => {
|
||||
try {
|
||||
return [JSON.parse(line) as MonitoringToolCallRecord]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async listRunIds(): Promise<string[]> {
|
||||
try {
|
||||
const entries = await readdir(getLazyMonitoringRunsDir(), {
|
||||
withFileTypes: true,
|
||||
})
|
||||
const directories = entries.filter(
|
||||
(entry) => entry.isDirectory() && isValidMonitoringRunId(entry.name),
|
||||
)
|
||||
const runStats = await Promise.all(
|
||||
directories.map(async (entry) => ({
|
||||
runId: entry.name,
|
||||
mtimeMs: await this.getDirectoryMtimeMs(entry.name),
|
||||
})),
|
||||
)
|
||||
return runStats
|
||||
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
||||
.map((entry) => entry.runId)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureRunDir(runId: string): Promise<void> {
|
||||
assertValidMonitoringRunId(runId)
|
||||
await mkdir(getLazyMonitoringRunsDir(), { recursive: true })
|
||||
await mkdir(getLazyMonitoringRunDir(runId), { recursive: true })
|
||||
}
|
||||
|
||||
private async getDirectoryMtimeMs(runId: string): Promise<number> {
|
||||
try {
|
||||
const info = await stat(getLazyMonitoringRunDir(runId))
|
||||
return info.mtimeMs
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
private async readJsonFile<T>(path: string): Promise<T | null> {
|
||||
try {
|
||||
const content = await readFile(path, 'utf8')
|
||||
return JSON.parse(content) as T
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private getContextPath(runId: string): string {
|
||||
assertValidMonitoringRunId(runId)
|
||||
return join(getLazyMonitoringRunDir(runId), CONTEXT_FILE_NAME)
|
||||
}
|
||||
|
||||
private getToolCallsPath(runId: string): string {
|
||||
assertValidMonitoringRunId(runId)
|
||||
return join(getLazyMonitoringRunDir(runId), TOOL_CALLS_FILE_NAME)
|
||||
}
|
||||
|
||||
private getFinalizationPath(runId: string): string {
|
||||
assertValidMonitoringRunId(runId)
|
||||
return join(getLazyMonitoringRunDir(runId), FINALIZATION_FILE_NAME)
|
||||
}
|
||||
|
||||
private getErrorLogPath(runId: string): string {
|
||||
assertValidMonitoringRunId(runId)
|
||||
return join(getLazyMonitoringRunDir(runId), ERROR_LOG_FILE_NAME)
|
||||
}
|
||||
|
||||
private getAuditEnvelopePath(runId: string): string {
|
||||
assertValidMonitoringRunId(runId)
|
||||
return join(getLazyMonitoringRunDir(runId), AUDIT_ENVELOPE_FILE_NAME)
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
export type MonitoringChatTurnRole = 'user' | 'assistant'
|
||||
|
||||
export interface MonitoringChatTurn {
|
||||
role: MonitoringChatTurnRole
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface MonitoringSessionContext {
|
||||
monitoringSessionId: string
|
||||
agentId: string
|
||||
sessionKey: string
|
||||
originalPrompt: string
|
||||
chatHistory: MonitoringChatTurn[]
|
||||
startedAt: string
|
||||
source: 'openclaw-agent-chat' | 'debug'
|
||||
}
|
||||
|
||||
export type MonitoringToolCallSource = 'browser-tool' | 'klavis-tool'
|
||||
|
||||
export interface MonitoringToolCallRecord {
|
||||
monitoringSessionId: string
|
||||
agentId: string
|
||||
toolCallId: string
|
||||
toolName: string
|
||||
toolDescription?: string
|
||||
source: MonitoringToolCallSource
|
||||
args: unknown
|
||||
output?: unknown
|
||||
error?: string
|
||||
startedAt: string
|
||||
finishedAt?: string
|
||||
durationMs?: number
|
||||
}
|
||||
|
||||
export interface MonitoringFinalization {
|
||||
monitoringSessionId: string
|
||||
agentId: string
|
||||
sessionKey: string
|
||||
status: 'completed' | 'failed' | 'aborted' | 'incomplete'
|
||||
finalAssistantMessage?: string
|
||||
error?: string
|
||||
finalizedAt: string
|
||||
}
|
||||
|
||||
export interface JudgeAuditEnvelope {
|
||||
run: MonitoringSessionContext
|
||||
toolCalls: MonitoringToolCallRecord[]
|
||||
finalization?: MonitoringFinalization
|
||||
}
|
||||
|
||||
export interface MonitoringRunSummary {
|
||||
monitoringSessionId: string
|
||||
agentId: string
|
||||
sessionKey: string
|
||||
originalPrompt: string
|
||||
startedAt: string
|
||||
source: MonitoringSessionContext['source']
|
||||
toolCallCount: number
|
||||
finalization?: Pick<
|
||||
MonitoringFinalization,
|
||||
'status' | 'finalizedAt' | 'error'
|
||||
>
|
||||
}
|
||||
|
||||
export interface MonitoringSessionStartInput {
|
||||
agentId: string
|
||||
sessionKey: string
|
||||
originalPrompt: string
|
||||
chatHistory: MonitoringChatTurn[]
|
||||
source?: MonitoringSessionContext['source']
|
||||
}
|
||||
|
||||
export interface MonitoringToolStartInput {
|
||||
toolCallId: string
|
||||
toolName: string
|
||||
toolDescription?: string
|
||||
source: MonitoringToolCallSource
|
||||
args: unknown
|
||||
}
|
||||
|
||||
export interface MonitoringToolEndInput {
|
||||
toolCallId: string
|
||||
output?: unknown
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface MonitoringFinalizeInput {
|
||||
monitoringSessionId: string
|
||||
agentId: string
|
||||
sessionKey: string
|
||||
status: MonitoringFinalization['status']
|
||||
finalAssistantMessage?: string
|
||||
error?: string
|
||||
}
|
||||
@@ -5,64 +5,19 @@ interface SemanticScore {
|
||||
backend: string
|
||||
}
|
||||
|
||||
interface EmbeddingOutput {
|
||||
tolist: () => number[][]
|
||||
dispose?: () => void
|
||||
}
|
||||
|
||||
interface FeatureExtractionPipeline {
|
||||
(
|
||||
texts: string[],
|
||||
options: { pooling: string; normalize: boolean },
|
||||
): Promise<EmbeddingOutput>
|
||||
dispose?: () => Promise<void>
|
||||
}
|
||||
type FeatureExtractionPipeline = (
|
||||
texts: string[],
|
||||
options: { pooling: string; normalize: boolean },
|
||||
) => Promise<{ tolist: () => number[][] }>
|
||||
|
||||
let pipelineInstance: FeatureExtractionPipeline | null = null
|
||||
const LOAD_RETRY_MS = 60_000
|
||||
let lastLoadFailedAt = 0
|
||||
let cleanupListener: (() => void) | null = null
|
||||
|
||||
function getModelName(): string {
|
||||
return process.env.ACL_EMBEDDING_MODEL ?? 'Xenova/bge-small-en-v1.5'
|
||||
}
|
||||
|
||||
function isSemanticDisabled(): boolean {
|
||||
return process.env.ACL_EMBEDDING_DISABLE === 'true'
|
||||
}
|
||||
|
||||
export async function disposeSemanticPipeline(): Promise<void> {
|
||||
const current = pipelineInstance
|
||||
pipelineInstance = null
|
||||
if (cleanupListener) {
|
||||
process.removeListener('beforeExit', cleanupListener)
|
||||
cleanupListener = null
|
||||
}
|
||||
if (!current?.dispose) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await current.dispose()
|
||||
} catch (error) {
|
||||
logger.warn('ACL embedding model disposal failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function registerPipelineCleanup(): void {
|
||||
if (cleanupListener) {
|
||||
return
|
||||
}
|
||||
cleanupListener = () => {
|
||||
// beforeExit cannot await async cleanup, so explicit disposal is still
|
||||
// required anywhere teardown must be deterministic.
|
||||
void disposeSemanticPipeline()
|
||||
}
|
||||
process.once('beforeExit', cleanupListener)
|
||||
}
|
||||
|
||||
async function ensurePipeline(): Promise<FeatureExtractionPipeline | null> {
|
||||
if (pipelineInstance) return pipelineInstance
|
||||
if (lastLoadFailedAt > 0 && Date.now() - lastLoadFailedAt < LOAD_RETRY_MS) {
|
||||
@@ -75,7 +30,6 @@ async function ensurePipeline(): Promise<FeatureExtractionPipeline | null> {
|
||||
dtype: 'fp32',
|
||||
})
|
||||
pipelineInstance = extractor as unknown as FeatureExtractionPipeline
|
||||
registerPipelineCleanup()
|
||||
lastLoadFailedAt = 0
|
||||
logger.info('ACL embedding model loaded', { model: getModelName() })
|
||||
return pipelineInstance
|
||||
@@ -110,7 +64,6 @@ export async function computeSemanticSimilarity(
|
||||
right: string,
|
||||
): Promise<SemanticScore> {
|
||||
if (!left || !right) return { score: 0, backend: 'none' }
|
||||
if (isSemanticDisabled()) return { score: 0, backend: 'disabled' }
|
||||
|
||||
const extractor = await ensurePipeline()
|
||||
if (!extractor) return { score: 0, backend: 'error' }
|
||||
@@ -121,7 +74,6 @@ export async function computeSemanticSimilarity(
|
||||
normalize: true,
|
||||
})
|
||||
const embeddings = output.tolist()
|
||||
output.dispose?.()
|
||||
const score = cosineSimilarity(embeddings[0], embeddings[1])
|
||||
return {
|
||||
score: Math.max(0, Math.min(score, 1)),
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const fixtureName = process.argv[2]
|
||||
if (!fixtureName) {
|
||||
throw new Error('Fixture name is required')
|
||||
}
|
||||
|
||||
process.env.LOG_LEVEL = 'silent'
|
||||
delete process.env.ACL_EMBEDDING_DISABLE
|
||||
|
||||
const [{ scoreFixture }, { disposeSemanticPipeline }] = await Promise.all([
|
||||
import('../../src/tools/acl/acl-scorer'),
|
||||
import('../../src/tools/acl/acl-embeddings'),
|
||||
])
|
||||
|
||||
const fixturePath = resolve(
|
||||
import.meta.dir,
|
||||
`../__fixtures__/acl/${fixtureName}.json`,
|
||||
)
|
||||
const fixture = await Bun.file(fixturePath).json()
|
||||
const decision = await scoreFixture(
|
||||
fixture.tool_name,
|
||||
fixture.page_url,
|
||||
fixture.element,
|
||||
fixture.rules,
|
||||
)
|
||||
await disposeSemanticPipeline()
|
||||
process.stdout.write(JSON.stringify(decision))
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(
|
||||
error instanceof Error ? (error.stack ?? error.message) : String(error),
|
||||
)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -91,13 +91,8 @@ export async function spawnBrowser(
|
||||
const browserProcess = spawn(
|
||||
config.binaryPath,
|
||||
[
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
'--use-mock-keychain',
|
||||
'--show-component-extension-options',
|
||||
// Match the supported dev/eval launch path and keep legacy BrowserOS
|
||||
// extensions from trying to talk to the removed controller bridge.
|
||||
'--disable-browseros-extensions',
|
||||
'--enable-logging=stderr',
|
||||
...(config.headless ? ['--headless=new'] : []),
|
||||
...config.extraArgs,
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { chmod, mkdtemp, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
export interface FakeLimactlResponse {
|
||||
stdout?: string
|
||||
stderr?: string
|
||||
exit?: number
|
||||
}
|
||||
|
||||
export async function fakeLimactl(
|
||||
canned: Record<string, FakeLimactlResponse>,
|
||||
logPath?: string,
|
||||
): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'fake-limactl-'))
|
||||
const path = join(dir, 'limactl')
|
||||
const limaHomeExpansion = '$' + '{LIMA_HOME-}'
|
||||
const cases = Object.entries(canned)
|
||||
.map(([command, response]) =>
|
||||
[
|
||||
` ${JSON.stringify(command)})`,
|
||||
` echo "ARGS:$*" >> "${logPath ?? '/dev/null'}"`,
|
||||
` echo "LIMA_HOME:${limaHomeExpansion}" >> "${logPath ?? '/dev/null'}"`,
|
||||
` printf %b ${JSON.stringify(response.stdout ?? '')}`,
|
||||
` printf %b ${JSON.stringify(response.stderr ?? '')} >&2`,
|
||||
` exit ${response.exit ?? 0}`,
|
||||
' ;;',
|
||||
].join('\n'),
|
||||
)
|
||||
.join('\n')
|
||||
const body = `#!/usr/bin/env bash
|
||||
set -u
|
||||
case "$1" in
|
||||
${cases}
|
||||
*)
|
||||
echo "ARGS:$*" >> "${logPath ?? '/dev/null'}"
|
||||
echo "unexpected subcommand: $1" >&2
|
||||
exit 99
|
||||
;;
|
||||
esac
|
||||
`
|
||||
await writeFile(path, body)
|
||||
await chmod(path, 0o755)
|
||||
return path
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { chmod, mkdtemp, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
export interface FakeSshResponse {
|
||||
stdout?: string
|
||||
stderr?: string
|
||||
exit?: number
|
||||
}
|
||||
|
||||
export async function fakeSsh(
|
||||
response: FakeSshResponse = {},
|
||||
logPath?: string,
|
||||
): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'fake-ssh-'))
|
||||
const path = join(dir, 'ssh')
|
||||
const body = `#!/usr/bin/env bash
|
||||
set -u
|
||||
echo "ARGS:$*" >> "${logPath ?? '/dev/null'}"
|
||||
printf %b ${JSON.stringify(response.stdout ?? '')}
|
||||
printf %b ${JSON.stringify(response.stderr ?? '')} >&2
|
||||
exit ${response.exit ?? 0}
|
||||
`
|
||||
await writeFile(path, body)
|
||||
await chmod(path, 0o755)
|
||||
return path
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, readdirSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
|
||||
const projectRoot = resolve(import.meta.dir, '..', '..')
|
||||
const testsRoot = resolve(projectRoot, 'tests')
|
||||
const cleanupScript = resolve(testsRoot, '__helpers__/cleanup.sh')
|
||||
const testPreloadPath = './tests/__helpers__/test-env.ts'
|
||||
const preferredDirectoryGroups = [
|
||||
'agent',
|
||||
'api',
|
||||
'skills',
|
||||
'tools',
|
||||
'browser',
|
||||
'sdk',
|
||||
]
|
||||
const ignoredDirectories = new Set(['__fixtures__', '__helpers__'])
|
||||
const rootGroupExclusions = new Set(['server.integration.test.ts'])
|
||||
const testFilePattern = /\.(test|spec)\.[cm]?[jt]sx?$/
|
||||
|
||||
function compareGroupNames(left: string, right: string): number {
|
||||
const leftIndex = preferredDirectoryGroups.indexOf(left)
|
||||
const rightIndex = preferredDirectoryGroups.indexOf(right)
|
||||
const leftRank =
|
||||
leftIndex === -1 ? preferredDirectoryGroups.length : leftIndex
|
||||
const rightRank =
|
||||
rightIndex === -1 ? preferredDirectoryGroups.length : rightIndex
|
||||
if (leftRank !== rightRank) {
|
||||
return leftRank - rightRank
|
||||
}
|
||||
return left.localeCompare(right)
|
||||
}
|
||||
|
||||
function listDirectoryGroups(): string[] {
|
||||
return readdirSync(testsRoot, { withFileTypes: true })
|
||||
.filter(
|
||||
(entry) => entry.isDirectory() && !ignoredDirectories.has(entry.name),
|
||||
)
|
||||
.map((entry) => entry.name)
|
||||
.sort(compareGroupNames)
|
||||
}
|
||||
|
||||
function listRootTestTargets(): string[] {
|
||||
return readdirSync(testsRoot, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && testFilePattern.test(entry.name))
|
||||
.filter((entry) => !rootGroupExclusions.has(entry.name))
|
||||
.map((entry) => `./tests/${entry.name}`)
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
}
|
||||
|
||||
function listAllGroups(): string[] {
|
||||
const groups = [...listDirectoryGroups()]
|
||||
if (existsSync(resolve(testsRoot, 'server.integration.test.ts'))) {
|
||||
groups.push('integration')
|
||||
}
|
||||
if (listRootTestTargets().length > 0) {
|
||||
groups.push('root')
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
function listAvailableGroupNames(): string[] {
|
||||
return ['all', 'core', 'cdp', ...listAllGroups()].sort((left, right) =>
|
||||
left.localeCompare(right),
|
||||
)
|
||||
}
|
||||
|
||||
function getCompositeGroupMembers(group: string): string[] | null {
|
||||
if (group === 'all') {
|
||||
return listAllGroups()
|
||||
}
|
||||
if (group === 'core') {
|
||||
return ['agent', 'api', 'skills', 'root']
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getAtomicGroupTargets(group: string): string[] {
|
||||
if (group === 'cdp') {
|
||||
return getAtomicGroupTargets('browser')
|
||||
}
|
||||
if (group === 'integration') {
|
||||
return existsSync(resolve(testsRoot, 'server.integration.test.ts'))
|
||||
? ['./tests/server.integration.test.ts']
|
||||
: []
|
||||
}
|
||||
if (group === 'root') {
|
||||
return listRootTestTargets()
|
||||
}
|
||||
if (existsSync(resolve(testsRoot, group))) {
|
||||
return [`./tests/${group}`]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function runCommand(cmd: string[], label: string): number {
|
||||
console.log(`\n==> ${label}`)
|
||||
const result = spawnSync(cmd[0], cmd.slice(1), {
|
||||
cwd: projectRoot,
|
||||
env: withTestEnv(process.env),
|
||||
stdio: 'inherit',
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
throw result.error
|
||||
}
|
||||
|
||||
return result.status ?? 1
|
||||
}
|
||||
|
||||
export function withTestEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
if (env.NODE_ENV) return env
|
||||
return { ...env, NODE_ENV: 'test' }
|
||||
}
|
||||
|
||||
export function buildTestCommand(
|
||||
targets: string[],
|
||||
junitPath?: string,
|
||||
): string[] {
|
||||
const cmd = [
|
||||
process.execPath,
|
||||
'--env-file=.env.development',
|
||||
'test',
|
||||
`--preload=${testPreloadPath}`,
|
||||
]
|
||||
if (junitPath) {
|
||||
const outputPath = resolve(projectRoot, junitPath)
|
||||
mkdirSync(dirname(outputPath), { recursive: true })
|
||||
cmd.push('--reporter=junit', `--reporter-outfile=${outputPath}`)
|
||||
}
|
||||
cmd.push(...targets)
|
||||
return cmd
|
||||
}
|
||||
|
||||
function runAtomicGroup(group: string): number {
|
||||
const targets = getAtomicGroupTargets(group)
|
||||
if (targets.length === 0) {
|
||||
throw new Error(
|
||||
`Unknown test group "${group}". Available groups: ${listAvailableGroupNames().join(', ')}`,
|
||||
)
|
||||
}
|
||||
runCommand(['bash', cleanupScript], `Cleaning up test resources for ${group}`)
|
||||
const junitPath = process.env.BROWSEROS_JUNIT_PATH?.trim()
|
||||
const cmd = buildTestCommand(targets, junitPath)
|
||||
return runCommand(cmd, `Running ${group} tests`)
|
||||
}
|
||||
|
||||
function runGroup(group: string): number {
|
||||
const compositeMembers = getCompositeGroupMembers(group)
|
||||
if (compositeMembers) {
|
||||
let exitCode = 0
|
||||
for (const member of compositeMembers) {
|
||||
const status = runGroup(member)
|
||||
if (status !== 0 && exitCode === 0) {
|
||||
exitCode = status
|
||||
}
|
||||
}
|
||||
return exitCode
|
||||
}
|
||||
return runAtomicGroup(group)
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const requestedGroup = process.argv[2] ?? 'all'
|
||||
process.exit(runGroup(requestedGroup))
|
||||
}
|
||||
@@ -26,49 +26,6 @@ interface ServerState {
|
||||
|
||||
let serverState: ServerState | null = null
|
||||
|
||||
function appendBufferedLog(buffer: string[], chunk: Buffer | string): void {
|
||||
const text = chunk.toString()
|
||||
const lines = text
|
||||
.split('\n')
|
||||
.map((line) => line.trimEnd())
|
||||
.filter((line) => line.length > 0)
|
||||
if (lines.length === 0) {
|
||||
return
|
||||
}
|
||||
buffer.push(...lines)
|
||||
const overflow = buffer.length - 40
|
||||
if (overflow > 0) {
|
||||
buffer.splice(0, overflow)
|
||||
}
|
||||
}
|
||||
|
||||
function formatStartupFailure(
|
||||
process: ChildProcess,
|
||||
port: number,
|
||||
stdoutBuffer: string[],
|
||||
stderrBuffer: string[],
|
||||
reason: string,
|
||||
): Error {
|
||||
const details: string[] = [reason]
|
||||
|
||||
if (process.exitCode !== null) {
|
||||
details.push(`exit code: ${process.exitCode}`)
|
||||
}
|
||||
if (process.signalCode) {
|
||||
details.push(`signal: ${process.signalCode}`)
|
||||
}
|
||||
|
||||
if (stderrBuffer.length > 0) {
|
||||
details.push(`stderr:\n${stderrBuffer.join('\n')}`)
|
||||
} else if (stdoutBuffer.length > 0) {
|
||||
details.push(`stdout:\n${stdoutBuffer.join('\n')}`)
|
||||
}
|
||||
|
||||
return new Error(
|
||||
`Server failed to start on port ${port}. ${details.join('\n\n')}`,
|
||||
)
|
||||
}
|
||||
|
||||
export async function isServerRunning(port: number): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/health`, {
|
||||
@@ -80,35 +37,14 @@ export async function isServerRunning(port: number): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForHealth(
|
||||
process: ChildProcess,
|
||||
port: number,
|
||||
stdoutBuffer: string[],
|
||||
stderrBuffer: string[],
|
||||
maxAttempts = 60,
|
||||
): Promise<void> {
|
||||
async function waitForHealth(port: number, maxAttempts = 30): Promise<void> {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
if (await isServerRunning(port)) {
|
||||
return
|
||||
}
|
||||
if (process.exitCode !== null || process.signalCode) {
|
||||
throw formatStartupFailure(
|
||||
process,
|
||||
port,
|
||||
stdoutBuffer,
|
||||
stderrBuffer,
|
||||
'Server process exited before /health became ready.',
|
||||
)
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
}
|
||||
throw formatStartupFailure(
|
||||
process,
|
||||
port,
|
||||
stdoutBuffer,
|
||||
stderrBuffer,
|
||||
'Timed out waiting for /health to become ready.',
|
||||
)
|
||||
throw new Error(`Server failed to start on port ${port} within timeout`)
|
||||
}
|
||||
|
||||
export function getServerState(): ServerState | null {
|
||||
@@ -132,8 +68,6 @@ export async function spawnServer(config: ServerConfig): Promise<ServerState> {
|
||||
}
|
||||
|
||||
console.log(`Starting BrowserOS Server on port ${config.serverPort}...`)
|
||||
const stdoutBuffer: string[] = []
|
||||
const stderrBuffer: string[] = []
|
||||
const process = spawn(
|
||||
'bun',
|
||||
[
|
||||
@@ -145,20 +79,18 @@ export async function spawnServer(config: ServerConfig): Promise<ServerState> {
|
||||
],
|
||||
{
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...globalThis.process.env,
|
||||
NODE_ENV: 'test',
|
||||
BROWSEROS_USE_MOCK_LLM: 'true',
|
||||
},
|
||||
env: { ...globalThis.process.env, NODE_ENV: 'test' },
|
||||
},
|
||||
)
|
||||
|
||||
process.stdout?.on('data', (data) => {
|
||||
appendBufferedLog(stdoutBuffer, data)
|
||||
process.stdout?.on('data', (_data) => {
|
||||
// Uncomment for debugging
|
||||
// console.log(`[SERVER] ${_data.toString().trim()}`)
|
||||
})
|
||||
|
||||
process.stderr?.on('data', (data) => {
|
||||
appendBufferedLog(stderrBuffer, data)
|
||||
process.stderr?.on('data', (_data) => {
|
||||
// Uncomment for debugging
|
||||
// console.error(`[SERVER] ${_data.toString().trim()}`)
|
||||
})
|
||||
|
||||
process.on('error', (error) => {
|
||||
@@ -166,7 +98,7 @@ export async function spawnServer(config: ServerConfig): Promise<ServerState> {
|
||||
})
|
||||
|
||||
console.log('Waiting for server to be ready...')
|
||||
await waitForHealth(process, config.serverPort, stdoutBuffer, stderrBuffer)
|
||||
await waitForHealth(config.serverPort)
|
||||
console.log('Server is ready')
|
||||
|
||||
serverState = { process, config }
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
process.env.NODE_ENV = 'test'
|
||||
|
||||
if (!process.env.BROWSEROS_DIR) {
|
||||
process.env.BROWSEROS_DIR = mkdtempSync(
|
||||
join(tmpdir(), 'browseros-server-test-home-'),
|
||||
)
|
||||
}
|
||||
|
||||
const portBase = 36000 + (process.pid % 1000) * 20
|
||||
|
||||
if (!process.env.BROWSEROS_TEST_CDP_PORT) {
|
||||
process.env.BROWSEROS_TEST_CDP_PORT = String(portBase)
|
||||
}
|
||||
if (!process.env.BROWSEROS_TEST_SERVER_PORT) {
|
||||
process.env.BROWSEROS_TEST_SERVER_PORT = String(portBase + 1)
|
||||
}
|
||||
if (!process.env.BROWSEROS_TEST_EXTENSION_PORT) {
|
||||
process.env.BROWSEROS_TEST_EXTENSION_PORT = String(portBase + 2)
|
||||
}
|
||||
if (!process.env.BROWSEROS_TEST_OPENCLAW_GATEWAY_PORT) {
|
||||
process.env.BROWSEROS_TEST_OPENCLAW_GATEWAY_PORT = String(portBase + 3)
|
||||
}
|
||||
@@ -1168,9 +1168,8 @@ describe('compaction E2E — pruning and output reduction', () => {
|
||||
{ role: 'user', content: 'x'.repeat(3000) },
|
||||
]
|
||||
const estimated = estimateTokensForThreshold(messages, config)
|
||||
expect(estimated).toBe(
|
||||
Math.ceil(1000 * config.safetyMultiplier) + config.fixedOverhead,
|
||||
)
|
||||
// 3000 chars / 3 = 1000 tokens, * 1.3 = 1300, + 12000 = 13300
|
||||
expect(estimated).toBe(Math.ceil(1000 * 1.3) + 12_000)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('createKlavisRoutes', () => {
|
||||
it('normalizes string integrations into unauthenticated entries', async () => {
|
||||
it('normalizes string integrations into authenticated entries', async () => {
|
||||
globalThis.fetch = (async () =>
|
||||
Response.json({
|
||||
integrations: ['Google Docs', 'Slack'],
|
||||
@@ -32,8 +32,8 @@ describe('createKlavisRoutes', () => {
|
||||
assert.strictEqual(response.status, 200)
|
||||
assert.deepStrictEqual(body, {
|
||||
integrations: [
|
||||
{ name: 'Google Docs', is_authenticated: false },
|
||||
{ name: 'Slack', is_authenticated: false },
|
||||
{ name: 'Google Docs', is_authenticated: true },
|
||||
{ name: 'Slack', is_authenticated: true },
|
||||
],
|
||||
count: 2,
|
||||
})
|
||||
|
||||
@@ -1,612 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 BrowserOS
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, mock } from 'bun:test'
|
||||
import { OpenClawSessionNotFoundError } from '../../../src/api/services/openclaw/errors'
|
||||
import { UnsupportedOpenClawProviderError } from '../../../src/api/services/openclaw/openclaw-provider-map'
|
||||
|
||||
describe('createOpenClawRoutes', () => {
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
it('preserves BrowserOS SSE framing and normalizes recursive session keys for chat', async () => {
|
||||
const actualOpenClawService = await import(
|
||||
'../../../src/api/services/openclaw/openclaw-service'
|
||||
)
|
||||
const chatStream = mock(
|
||||
async () =>
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue({
|
||||
type: 'text-delta',
|
||||
data: { text: 'Hello' },
|
||||
})
|
||||
controller.enqueue({
|
||||
type: 'done',
|
||||
data: { text: 'Hello' },
|
||||
})
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
|
||||
...actualOpenClawService,
|
||||
getOpenClawService: () =>
|
||||
({
|
||||
chatStream,
|
||||
}) as never,
|
||||
}))
|
||||
|
||||
const { createOpenClawRoutes } = await import(
|
||||
'../../../src/api/routes/openclaw'
|
||||
)
|
||||
const route = createOpenClawRoutes()
|
||||
|
||||
const response = await route.request('/agents/research/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: 'hi',
|
||||
sessionKey:
|
||||
'agent:research:openai-user:browseros:research:agent:research:openai-user:browseros:research:session-123',
|
||||
}),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Content-Type')).toContain('text/event-stream')
|
||||
expect(response.headers.get('X-Session-Key')).toBe('session-123')
|
||||
expect(chatStream).toHaveBeenCalledWith('research', 'session-123', 'hi', [])
|
||||
expect(await response.text()).toBe(
|
||||
'data: {"type":"text-delta","data":{"text":"Hello"}}\n\n' +
|
||||
'data: {"type":"done","data":{"text":"Hello"}}\n\n' +
|
||||
'data: [DONE]\n\n',
|
||||
)
|
||||
})
|
||||
|
||||
it('passes prior chat history through to the OpenClaw chat stream', async () => {
|
||||
const actualOpenClawService = await import(
|
||||
'../../../src/api/services/openclaw/openclaw-service'
|
||||
)
|
||||
const chatStream = mock(
|
||||
async () =>
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue({
|
||||
type: 'done',
|
||||
data: { text: 'Done' },
|
||||
})
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
|
||||
...actualOpenClawService,
|
||||
getOpenClawService: () =>
|
||||
({
|
||||
chatStream,
|
||||
}) as never,
|
||||
}))
|
||||
|
||||
const { createOpenClawRoutes } = await import(
|
||||
'../../../src/api/routes/openclaw'
|
||||
)
|
||||
const route = createOpenClawRoutes()
|
||||
const history = [
|
||||
{ role: 'user' as const, content: 'Find my open tasks' },
|
||||
{ role: 'assistant' as const, content: 'I am checking Linear now.' },
|
||||
]
|
||||
|
||||
const response = await route.request('/agents/research/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: 'Summarize what is blocked',
|
||||
sessionKey: 'session-456',
|
||||
history,
|
||||
}),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(chatStream).toHaveBeenCalledWith(
|
||||
'research',
|
||||
'session-456',
|
||||
'Summarize what is blocked',
|
||||
history,
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects concurrent monitored chat requests for the same agent', async () => {
|
||||
const actualOpenClawService = await import(
|
||||
'../../../src/api/services/openclaw/openclaw-service'
|
||||
)
|
||||
const actualMonitoringService = await import(
|
||||
'../../../src/monitoring/service'
|
||||
)
|
||||
const chatStream = mock(async () => new ReadableStream())
|
||||
|
||||
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
|
||||
...actualOpenClawService,
|
||||
getOpenClawService: () =>
|
||||
({
|
||||
chatStream,
|
||||
}) as never,
|
||||
}))
|
||||
|
||||
mock.module('../../../src/monitoring/service', () => ({
|
||||
...actualMonitoringService,
|
||||
getMonitoringService: () =>
|
||||
({
|
||||
getActiveSessionId: (agentId: string) =>
|
||||
agentId === 'research' ? 'existing-run' : undefined,
|
||||
}) as never,
|
||||
}))
|
||||
|
||||
const { createOpenClawRoutes } = await import(
|
||||
'../../../src/api/routes/openclaw'
|
||||
)
|
||||
const route = createOpenClawRoutes()
|
||||
|
||||
const response = await route.request('/agents/research/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: 'hi',
|
||||
sessionKey: 'session-789',
|
||||
}),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(409)
|
||||
expect(chatStream).not.toHaveBeenCalled()
|
||||
expect(await response.json()).toEqual({
|
||||
error:
|
||||
'A monitored chat session is already active for this agent. Wait for it to finish before starting another.',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns 400 for unsupported provider payloads', async () => {
|
||||
const actualOpenClawService = await import(
|
||||
'../../../src/api/services/openclaw/openclaw-service'
|
||||
)
|
||||
const updateProviderKeys = mock(async () => {
|
||||
throw new UnsupportedOpenClawProviderError('google')
|
||||
})
|
||||
|
||||
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
|
||||
...actualOpenClawService,
|
||||
getOpenClawService: () =>
|
||||
({
|
||||
updateProviderKeys,
|
||||
}) as never,
|
||||
}))
|
||||
|
||||
const { createOpenClawRoutes } = await import(
|
||||
'../../../src/api/routes/openclaw'
|
||||
)
|
||||
const route = createOpenClawRoutes()
|
||||
|
||||
const response = await route.request('/providers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
providerType: 'google',
|
||||
apiKey: 'google-key',
|
||||
}),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(updateProviderKeys).toHaveBeenCalledWith({
|
||||
providerType: 'google',
|
||||
apiKey: 'google-key',
|
||||
})
|
||||
expect(await response.json()).toEqual({
|
||||
error: 'Unsupported OpenClaw provider: google',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns a non-restarting response when only the default model changes', async () => {
|
||||
const actualOpenClawService = await import(
|
||||
'../../../src/api/services/openclaw/openclaw-service'
|
||||
)
|
||||
const updateProviderKeys = mock(async () => ({
|
||||
restarted: false,
|
||||
modelUpdated: true,
|
||||
}))
|
||||
|
||||
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
|
||||
...actualOpenClawService,
|
||||
getOpenClawService: () =>
|
||||
({
|
||||
updateProviderKeys,
|
||||
}) as never,
|
||||
}))
|
||||
|
||||
const { createOpenClawRoutes } = await import(
|
||||
'../../../src/api/routes/openclaw'
|
||||
)
|
||||
const route = createOpenClawRoutes()
|
||||
|
||||
const response = await route.request('/providers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
providerType: 'openai',
|
||||
apiKey: 'sk-test',
|
||||
modelId: 'gpt-5.4-mini',
|
||||
}),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(updateProviderKeys).toHaveBeenCalledWith({
|
||||
providerType: 'openai',
|
||||
apiKey: 'sk-test',
|
||||
modelId: 'gpt-5.4-mini',
|
||||
})
|
||||
expect(await response.json()).toEqual({
|
||||
status: 'updated',
|
||||
message: 'Provider updated without a restart',
|
||||
})
|
||||
})
|
||||
|
||||
it('does not expose a roles route', async () => {
|
||||
const { createOpenClawRoutes } = await import(
|
||||
'../../../src/api/routes/openclaw'
|
||||
)
|
||||
const route = createOpenClawRoutes()
|
||||
|
||||
const response = await route.request('/roles')
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns OpenClaw sessions for an agent', async () => {
|
||||
const actualOpenClawService = await import(
|
||||
'../../../src/api/services/openclaw/openclaw-service'
|
||||
)
|
||||
const listSessions = mock(async () => [
|
||||
{
|
||||
key: 'openai-user:browseros:main:session-1',
|
||||
updatedAt: 20,
|
||||
sessionId: 'session-1',
|
||||
agentId: 'main',
|
||||
kind: 'chat',
|
||||
source: 'user-chat',
|
||||
},
|
||||
])
|
||||
|
||||
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
|
||||
...actualOpenClawService,
|
||||
getOpenClawService: () => ({ listSessions }) as never,
|
||||
}))
|
||||
|
||||
const { createOpenClawRoutes } = await import(
|
||||
'../../../src/api/routes/openclaw'
|
||||
)
|
||||
const route = createOpenClawRoutes()
|
||||
|
||||
const response = await route.request('/agents/main/sessions?limit=1')
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(listSessions).toHaveBeenCalledWith('main')
|
||||
expect(await response.json()).toEqual({
|
||||
agentId: 'main',
|
||||
sessions: [
|
||||
{
|
||||
key: 'openai-user:browseros:main:session-1',
|
||||
updatedAt: 20,
|
||||
sessionId: 'session-1',
|
||||
agentId: 'main',
|
||||
kind: 'chat',
|
||||
source: 'user-chat',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the resolved active OpenClaw session for an agent', async () => {
|
||||
const actualOpenClawService = await import(
|
||||
'../../../src/api/services/openclaw/openclaw-service'
|
||||
)
|
||||
const resolveAgentSession = mock(async () => ({
|
||||
agentId: 'main',
|
||||
exists: true,
|
||||
sessionKey: 'session-1',
|
||||
session: {
|
||||
key: 'session-1',
|
||||
updatedAt: 20,
|
||||
sessionId: 'session-1',
|
||||
agentId: 'main',
|
||||
kind: 'chat',
|
||||
source: 'other',
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
|
||||
...actualOpenClawService,
|
||||
getOpenClawService: () => ({ resolveAgentSession }) as never,
|
||||
}))
|
||||
|
||||
const { createOpenClawRoutes } = await import(
|
||||
'../../../src/api/routes/openclaw'
|
||||
)
|
||||
const route = createOpenClawRoutes()
|
||||
|
||||
const response = await route.request('/agents/main/session')
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(resolveAgentSession).toHaveBeenCalledWith('main')
|
||||
expect(await response.json()).toEqual({
|
||||
agentId: 'main',
|
||||
exists: true,
|
||||
sessionKey: 'session-1',
|
||||
session: {
|
||||
key: 'session-1',
|
||||
updatedAt: 20,
|
||||
sessionId: 'session-1',
|
||||
agentId: 'main',
|
||||
kind: 'chat',
|
||||
source: 'other',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('returns a normalized OpenClaw history page for an agent', async () => {
|
||||
const actualOpenClawService = await import(
|
||||
'../../../src/api/services/openclaw/openclaw-service'
|
||||
)
|
||||
const getAgentHistoryPage = mock(async () => ({
|
||||
agentId: 'main',
|
||||
sessionKey: 'session-1',
|
||||
session: {
|
||||
key: 'session-1',
|
||||
updatedAt: 20,
|
||||
sessionId: 'session-1',
|
||||
agentId: 'main',
|
||||
kind: 'chat',
|
||||
source: 'other',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
id: 'session-1:0',
|
||||
role: 'user',
|
||||
text: 'Hello',
|
||||
timestamp: 1,
|
||||
messageSeq: 0,
|
||||
sessionKey: 'session-1',
|
||||
source: 'other',
|
||||
},
|
||||
],
|
||||
page: {
|
||||
cursor: 'older-cursor',
|
||||
hasMore: true,
|
||||
limit: 25,
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
|
||||
...actualOpenClawService,
|
||||
getOpenClawService: () => ({ getAgentHistoryPage }) as never,
|
||||
}))
|
||||
|
||||
const { createOpenClawRoutes } = await import(
|
||||
'../../../src/api/routes/openclaw'
|
||||
)
|
||||
const route = createOpenClawRoutes()
|
||||
|
||||
const response = await route.request(
|
||||
'/agents/main/history?sessionKey=session-1&cursor=abc&limit=25',
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(getAgentHistoryPage).toHaveBeenCalledWith('main', {
|
||||
sessionKey: 'session-1',
|
||||
cursor: 'abc',
|
||||
limit: 25,
|
||||
})
|
||||
expect(await response.json()).toEqual({
|
||||
agentId: 'main',
|
||||
sessionKey: 'session-1',
|
||||
session: {
|
||||
key: 'session-1',
|
||||
updatedAt: 20,
|
||||
sessionId: 'session-1',
|
||||
agentId: 'main',
|
||||
kind: 'chat',
|
||||
source: 'other',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
id: 'session-1:0',
|
||||
role: 'user',
|
||||
text: 'Hello',
|
||||
timestamp: 1,
|
||||
messageSeq: 0,
|
||||
sessionKey: 'session-1',
|
||||
source: 'other',
|
||||
},
|
||||
],
|
||||
page: {
|
||||
cursor: 'older-cursor',
|
||||
hasMore: true,
|
||||
limit: 25,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores role fields when creating agents', async () => {
|
||||
const actualOpenClawService = await import(
|
||||
'../../../src/api/services/openclaw/openclaw-service'
|
||||
)
|
||||
const createAgent = mock(async () => ({
|
||||
agentId: 'research',
|
||||
name: 'research',
|
||||
workspace: '/home/node/.openclaw/workspace-research',
|
||||
}))
|
||||
|
||||
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
|
||||
...actualOpenClawService,
|
||||
getOpenClawService: () =>
|
||||
({
|
||||
createAgent,
|
||||
}) as never,
|
||||
}))
|
||||
|
||||
const { createOpenClawRoutes } = await import(
|
||||
'../../../src/api/routes/openclaw'
|
||||
)
|
||||
const route = createOpenClawRoutes()
|
||||
|
||||
const response = await route.request('/agents', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: 'research',
|
||||
roleId: 'chief-of-staff',
|
||||
customRole: {
|
||||
name: 'Ignored',
|
||||
shortDescription: 'Ignored',
|
||||
longDescription: 'Ignored',
|
||||
recommendedApps: [],
|
||||
boundaries: [],
|
||||
},
|
||||
providerType: 'openai',
|
||||
apiKey: 'sk-test',
|
||||
modelId: 'gpt-5.4-mini',
|
||||
}),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(201)
|
||||
expect(createAgent).toHaveBeenCalledWith({
|
||||
name: 'research',
|
||||
providerType: 'openai',
|
||||
providerName: undefined,
|
||||
baseUrl: undefined,
|
||||
apiKey: 'sk-test',
|
||||
modelId: 'gpt-5.4-mini',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns JSON history from the session history route and forwards query params', async () => {
|
||||
const actualOpenClawService = await import(
|
||||
'../../../src/api/services/openclaw/openclaw-service'
|
||||
)
|
||||
const getSessionHistory = mock(async () => ({
|
||||
sessionKey: 'agent:main:main',
|
||||
messages: [{ role: 'user', content: 'hi', messageSeq: 1 }],
|
||||
cursor: null,
|
||||
hasMore: false,
|
||||
}))
|
||||
|
||||
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
|
||||
...actualOpenClawService,
|
||||
getOpenClawService: () => ({ getSessionHistory }) as never,
|
||||
}))
|
||||
|
||||
const { createOpenClawRoutes } = await import(
|
||||
'../../../src/api/routes/openclaw'
|
||||
)
|
||||
const route = createOpenClawRoutes()
|
||||
|
||||
const response = await route.request(
|
||||
'/session/agent%3Amain%3Amain/history?limit=25&cursor=next',
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Content-Type')).toContain('application/json')
|
||||
expect(getSessionHistory).toHaveBeenCalledWith('agent:main:main', {
|
||||
limit: 25,
|
||||
cursor: 'next',
|
||||
})
|
||||
expect(await response.json()).toEqual({
|
||||
sessionKey: 'agent:main:main',
|
||||
messages: [{ role: 'user', content: 'hi', messageSeq: 1 }],
|
||||
cursor: null,
|
||||
hasMore: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns 404 when the service reports a missing session', async () => {
|
||||
const actualOpenClawService = await import(
|
||||
'../../../src/api/services/openclaw/openclaw-service'
|
||||
)
|
||||
const getSessionHistory = mock(async () => {
|
||||
throw new OpenClawSessionNotFoundError('missing')
|
||||
})
|
||||
|
||||
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
|
||||
...actualOpenClawService,
|
||||
getOpenClawService: () => ({ getSessionHistory }) as never,
|
||||
}))
|
||||
|
||||
const { createOpenClawRoutes } = await import(
|
||||
'../../../src/api/routes/openclaw'
|
||||
)
|
||||
const route = createOpenClawRoutes()
|
||||
|
||||
const response = await route.request('/session/missing/history')
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(await response.json()).toEqual({
|
||||
error: 'OpenClaw session not found: missing',
|
||||
})
|
||||
})
|
||||
|
||||
it('streams named SSE frames when Accept: text/event-stream', async () => {
|
||||
const actualOpenClawService = await import(
|
||||
'../../../src/api/services/openclaw/openclaw-service'
|
||||
)
|
||||
const streamSessionHistory = mock(
|
||||
async () =>
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue({
|
||||
type: 'history',
|
||||
data: {
|
||||
sessionKey: 'k',
|
||||
messages: [],
|
||||
cursor: null,
|
||||
hasMore: false,
|
||||
},
|
||||
})
|
||||
controller.enqueue({
|
||||
type: 'message',
|
||||
data: {
|
||||
sessionKey: 'k',
|
||||
messageSeq: 2,
|
||||
message: { role: 'assistant', content: 'hi', messageSeq: 2 },
|
||||
},
|
||||
})
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
mock.module('../../../src/api/services/openclaw/openclaw-service', () => ({
|
||||
...actualOpenClawService,
|
||||
getOpenClawService: () => ({ streamSessionHistory }) as never,
|
||||
}))
|
||||
|
||||
const { createOpenClawRoutes } = await import(
|
||||
'../../../src/api/routes/openclaw'
|
||||
)
|
||||
const route = createOpenClawRoutes()
|
||||
|
||||
const response = await route.request('/session/k/history', {
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Content-Type')).toContain('text/event-stream')
|
||||
expect(response.headers.get('X-Session-Key')).toBe('k')
|
||||
expect(streamSessionHistory).toHaveBeenCalledTimes(1)
|
||||
expect(streamSessionHistory.mock.calls[0]?.[0]).toBe('k')
|
||||
expect(await response.text()).toBe(
|
||||
'event: history\ndata: {"sessionKey":"k","messages":[],"cursor":null,"hasMore":false}\n\n' +
|
||||
'event: message\ndata: {"sessionKey":"k","messageSeq":2,"message":{"role":"assistant","content":"hi","messageSeq":2}}\n\n',
|
||||
)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user