mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-27 01:43:29 +08:00
Compare commits
1 Commits
vincentkoc
...
fix/issue-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63f82e02b8 |
8
.github/workflows/docker-release.yml
vendored
8
.github/workflows/docker-release.yml
vendored
@@ -109,8 +109,6 @@ jobs:
|
||||
labels: ${{ steps.labels.outputs.value }}
|
||||
provenance: false
|
||||
push: true
|
||||
cache-from: type=gha,scope=docker-release-amd64
|
||||
cache-to: type=gha,mode=max,scope=docker-release-amd64
|
||||
|
||||
- name: Build and push amd64 slim image
|
||||
id: build-slim
|
||||
@@ -124,8 +122,6 @@ jobs:
|
||||
labels: ${{ steps.labels.outputs.value }}
|
||||
provenance: false
|
||||
push: true
|
||||
cache-from: type=gha,scope=docker-release-amd64
|
||||
cache-to: type=gha,mode=max,scope=docker-release-amd64
|
||||
|
||||
# Build arm64 images (default + slim share the build stage cache)
|
||||
build-arm64:
|
||||
@@ -214,8 +210,6 @@ jobs:
|
||||
labels: ${{ steps.labels.outputs.value }}
|
||||
provenance: false
|
||||
push: true
|
||||
cache-from: type=gha,scope=docker-release-arm64
|
||||
cache-to: type=gha,mode=max,scope=docker-release-arm64
|
||||
|
||||
- name: Build and push arm64 slim image
|
||||
id: build-slim
|
||||
@@ -229,8 +223,6 @@ jobs:
|
||||
labels: ${{ steps.labels.outputs.value }}
|
||||
provenance: false
|
||||
push: true
|
||||
cache-from: type=gha,scope=docker-release-arm64
|
||||
cache-to: type=gha,mode=max,scope=docker-release-arm64
|
||||
|
||||
# Create multi-platform manifests
|
||||
create-manifest:
|
||||
|
||||
@@ -227,7 +227,7 @@
|
||||
"filename": "apps/macos/Sources/OpenClawProtocol/GatewayModels.swift",
|
||||
"hashed_secret": "7990585255d25249fb1e6eac3d2bd6c37429b2cd",
|
||||
"is_verified": false,
|
||||
"line_number": 1763
|
||||
"line_number": 1749
|
||||
}
|
||||
],
|
||||
"apps/macos/Tests/OpenClawIPCTests/AnthropicAuthResolverTests.swift": [
|
||||
@@ -288,7 +288,7 @@
|
||||
"filename": "apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift",
|
||||
"hashed_secret": "7990585255d25249fb1e6eac3d2bd6c37429b2cd",
|
||||
"is_verified": false,
|
||||
"line_number": 1763
|
||||
"line_number": 1749
|
||||
}
|
||||
],
|
||||
"docs/.i18n/zh-CN.tm.jsonl": [
|
||||
@@ -11584,7 +11584,7 @@
|
||||
"filename": "src/agents/pi-embedded-runner/model.ts",
|
||||
"hashed_secret": "e774aaeac31c6272107ba89080295e277050fa7c",
|
||||
"is_verified": false,
|
||||
"line_number": 331
|
||||
"line_number": 267
|
||||
}
|
||||
],
|
||||
"src/agents/pi-embedded-runner/run.overflow-compaction.mocks.shared.ts": [
|
||||
@@ -13035,5 +13035,5 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"generated_at": "2026-03-09T01:01:03Z"
|
||||
"generated_at": "2026-03-08T20:41:38Z"
|
||||
}
|
||||
|
||||
@@ -44,8 +44,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Podman/setup: fix `cannot chdir: Permission denied` in `run_as_user` when `setup-podman.sh` is invoked from a directory the target user cannot access, by wrapping user-switch calls in a subshell that cd's to `/tmp` with `/` fallback. (#39435) Thanks @langdon and @jlcbk.
|
||||
- Podman/SELinux: auto-detect SELinux enforcing/permissive mode and add `:Z` relabel to bind mounts in `run-openclaw-podman.sh` and the Quadlet template, fixing `EACCES` on Fedora/RHEL hosts. Supports `OPENCLAW_BIND_MOUNT_OPTIONS` override. (#39449) Thanks @langdon and @githubbzxs.
|
||||
- TUI/theme: detect light terminal backgrounds via `COLORFGBG` and pick a WCAG AA-compliant light palette, with `OPENCLAW_THEME=light|dark` override for terminals without auto-detection. (#38636) Thanks @ademczuk and @vincentkoc.
|
||||
- Agents/context-engine plugins: bootstrap runtime plugins once at embedded-run, compaction, and subagent boundaries so plugin-provided context engines and hooks load from the active workspace before runtime resolution. (#40232)
|
||||
- Config/runtime snapshots: keep secrets-runtime-resolved config and auth-profile snapshots intact after config writes so follow-up reads still see file-backed secret values while picking up the persisted config update. (#37313) thanks @bbblending.
|
||||
|
||||
## 2026.3.7
|
||||
|
||||
|
||||
38
Dockerfile
38
Dockerfile
@@ -1,5 +1,3 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# Opt-in extension dependencies at build time (space-separated directory names).
|
||||
# Example: docker build --build-arg OPENCLAW_EXTENSIONS="diagnostics-otel matrix" .
|
||||
#
|
||||
@@ -50,13 +48,13 @@ WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
|
||||
COPY ui/package.json ./ui/package.json
|
||||
COPY patches ./patches
|
||||
COPY scripts ./scripts
|
||||
|
||||
COPY --from=ext-deps /out/ ./extensions/
|
||||
|
||||
# Reduce OOM risk on low-memory hosts during dependency installation.
|
||||
# Docker builds on small VMs may otherwise fail with "Killed" (exit 137).
|
||||
RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \
|
||||
NODE_OPTIONS=--max-old-space-size=2048 pnpm install --frozen-lockfile
|
||||
RUN NODE_OPTIONS=--max-old-space-size=2048 pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
|
||||
@@ -119,11 +117,11 @@ WORKDIR /app
|
||||
|
||||
# Install system utilities present in bookworm but missing in bookworm-slim.
|
||||
# On the full bookworm image these are already installed (apt-get is a no-op).
|
||||
RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
|
||||
apt-get update && \
|
||||
RUN apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
procps hostname curl git openssl
|
||||
procps hostname curl git openssl && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
|
||||
|
||||
RUN chown node:node /app
|
||||
|
||||
@@ -147,11 +145,11 @@ RUN install -d -m 0755 "$COREPACK_HOME" && \
|
||||
# Install additional system packages needed by your skills or extensions.
|
||||
# Example: docker build --build-arg OPENCLAW_DOCKER_APT_PACKAGES="python3 wget" .
|
||||
ARG OPENCLAW_DOCKER_APT_PACKAGES=""
|
||||
RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
|
||||
if [ -n "$OPENCLAW_DOCKER_APT_PACKAGES" ]; then \
|
||||
RUN if [ -n "$OPENCLAW_DOCKER_APT_PACKAGES" ]; then \
|
||||
apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $OPENCLAW_DOCKER_APT_PACKAGES; \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $OPENCLAW_DOCKER_APT_PACKAGES && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \
|
||||
fi
|
||||
|
||||
# Optionally install Chromium and Xvfb for browser automation.
|
||||
@@ -159,15 +157,15 @@ RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,shar
|
||||
# Adds ~300MB but eliminates the 60-90s Playwright install on every container start.
|
||||
# Must run after node_modules COPY so playwright-core is available.
|
||||
ARG OPENCLAW_INSTALL_BROWSER=""
|
||||
RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
|
||||
if [ -n "$OPENCLAW_INSTALL_BROWSER" ]; then \
|
||||
RUN if [ -n "$OPENCLAW_INSTALL_BROWSER" ]; then \
|
||||
apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends xvfb && \
|
||||
mkdir -p /home/node/.cache/ms-playwright && \
|
||||
PLAYWRIGHT_BROWSERS_PATH=/home/node/.cache/ms-playwright \
|
||||
node /app/node_modules/playwright-core/cli.js install --with-deps chromium && \
|
||||
chown -R node:node /home/node/.cache/ms-playwright; \
|
||||
chown -R node:node /home/node/.cache/ms-playwright && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \
|
||||
fi
|
||||
|
||||
# Optionally install Docker CLI for sandbox container management.
|
||||
@@ -176,9 +174,7 @@ RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,shar
|
||||
# Required for agents.defaults.sandbox to function in Docker deployments.
|
||||
ARG OPENCLAW_INSTALL_DOCKER_CLI=""
|
||||
ARG OPENCLAW_DOCKER_GPG_FINGERPRINT="9DC858229FC7DD38854AE2D88D81803C0EBFCD88"
|
||||
RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
|
||||
if [ -n "$OPENCLAW_INSTALL_DOCKER_CLI" ]; then \
|
||||
RUN if [ -n "$OPENCLAW_INSTALL_DOCKER_CLI" ]; then \
|
||||
apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl gnupg && \
|
||||
@@ -199,7 +195,9 @@ RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,shar
|
||||
"$(dpkg --print-architecture)" > /etc/apt/sources.list.d/docker.list && \
|
||||
apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
docker-ce-cli docker-compose-plugin; \
|
||||
docker-ce-cli docker-compose-plugin && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \
|
||||
fi
|
||||
|
||||
# Expose the CLI binary without requiring npm global writes as non-root.
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM debian:bookworm-slim@sha256:98f4b71de414932439ac6ac690d7060df1f27161073c5036a7553723881bffbe
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN --mount=type=cache,id=openclaw-sandbox-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=openclaw-sandbox-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
|
||||
apt-get update \
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
bash \
|
||||
ca-certificates \
|
||||
@@ -14,7 +10,8 @@ RUN --mount=type=cache,id=openclaw-sandbox-bookworm-apt-cache,target=/var/cache/
|
||||
git \
|
||||
jq \
|
||||
python3 \
|
||||
ripgrep
|
||||
ripgrep \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN useradd --create-home --shell /bin/bash sandbox
|
||||
USER sandbox
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM debian:bookworm-slim@sha256:98f4b71de414932439ac6ac690d7060df1f27161073c5036a7553723881bffbe
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN --mount=type=cache,id=openclaw-sandbox-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=openclaw-sandbox-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
|
||||
apt-get update \
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
bash \
|
||||
ca-certificates \
|
||||
@@ -21,7 +17,8 @@ RUN --mount=type=cache,id=openclaw-sandbox-bookworm-apt-cache,target=/var/cache/
|
||||
socat \
|
||||
websockify \
|
||||
x11vnc \
|
||||
xvfb
|
||||
xvfb \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --chmod=755 scripts/sandbox-browser-entrypoint.sh /usr/local/bin/openclaw-sandbox-browser
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
ARG BASE_IMAGE=openclaw-sandbox:bookworm-slim
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
@@ -21,10 +19,9 @@ ENV HOMEBREW_CELLAR=${BREW_INSTALL_DIR}/Cellar
|
||||
ENV HOMEBREW_REPOSITORY=${BREW_INSTALL_DIR}/Homebrew
|
||||
ENV PATH=${BUN_INSTALL_DIR}/bin:${BREW_INSTALL_DIR}/bin:${BREW_INSTALL_DIR}/sbin:${PATH}
|
||||
|
||||
RUN --mount=type=cache,id=openclaw-sandbox-common-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=openclaw-sandbox-common-apt-lists,target=/var/lib/apt,sharing=locked \
|
||||
apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ${PACKAGES}
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ${PACKAGES} \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN if [ "${INSTALL_PNPM}" = "1" ]; then npm install -g pnpm; fi
|
||||
|
||||
@@ -45,3 +42,4 @@ fi
|
||||
|
||||
# Default is sandbox, but allow BASE_IMAGE overrides to select another final user.
|
||||
USER ${FINAL_USER}
|
||||
|
||||
|
||||
@@ -46,19 +46,3 @@ export function isRetryableReconnectError(err) {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isMissingTabError(err) {
|
||||
const message = (err instanceof Error ? err.message : String(err || "")).toLowerCase();
|
||||
return (
|
||||
message.includes("no tab with id") ||
|
||||
message.includes("no tab with given id") ||
|
||||
message.includes("tab not found")
|
||||
);
|
||||
}
|
||||
|
||||
export function isLastRemainingTab(allTabs, tabIdToClose) {
|
||||
if (!Array.isArray(allTabs)) {
|
||||
return true;
|
||||
}
|
||||
return allTabs.filter((tab) => tab && tab.id !== tabIdToClose).length === 0;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
buildRelayWsUrl,
|
||||
isLastRemainingTab,
|
||||
isMissingTabError,
|
||||
isRetryableReconnectError,
|
||||
reconnectDelayMs,
|
||||
} from './background-utils.js'
|
||||
import { buildRelayWsUrl, isRetryableReconnectError, reconnectDelayMs } from './background-utils.js'
|
||||
|
||||
const DEFAULT_PORT = 18792
|
||||
|
||||
@@ -47,9 +41,6 @@ const reattachPending = new Set()
|
||||
let reconnectAttempt = 0
|
||||
let reconnectTimer = null
|
||||
|
||||
const TAB_VALIDATION_ATTEMPTS = 2
|
||||
const TAB_VALIDATION_RETRY_DELAY_MS = 1000
|
||||
|
||||
function nowStack() {
|
||||
try {
|
||||
return new Error().stack || ''
|
||||
@@ -58,37 +49,6 @@ function nowStack() {
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
async function validateAttachedTab(tabId) {
|
||||
try {
|
||||
await chrome.tabs.get(tabId)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
for (let attempt = 0; attempt < TAB_VALIDATION_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
|
||||
expression: '1',
|
||||
returnByValue: true,
|
||||
})
|
||||
return true
|
||||
} catch (err) {
|
||||
if (isMissingTabError(err)) {
|
||||
return false
|
||||
}
|
||||
if (attempt < TAB_VALIDATION_ATTEMPTS - 1) {
|
||||
await sleep(TAB_VALIDATION_RETRY_DELAY_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
async function getRelayPort() {
|
||||
const stored = await chrome.storage.local.get(['relayPort'])
|
||||
const raw = stored.relayPort
|
||||
@@ -148,11 +108,15 @@ async function rehydrateState() {
|
||||
tabBySession.set(entry.sessionId, entry.tabId)
|
||||
setBadge(entry.tabId, 'on')
|
||||
}
|
||||
// Retry once so transient busy/navigation states do not permanently drop
|
||||
// a still-attached tab after a service worker restart.
|
||||
// Phase 2: validate asynchronously, remove dead tabs.
|
||||
for (const entry of entries) {
|
||||
const valid = await validateAttachedTab(entry.tabId)
|
||||
if (!valid) {
|
||||
try {
|
||||
await chrome.tabs.get(entry.tabId)
|
||||
await chrome.debugger.sendCommand({ tabId: entry.tabId }, 'Runtime.evaluate', {
|
||||
expression: '1',
|
||||
returnByValue: true,
|
||||
})
|
||||
} catch {
|
||||
tabs.delete(entry.tabId)
|
||||
tabBySession.delete(entry.sessionId)
|
||||
setBadge(entry.tabId, 'off')
|
||||
@@ -295,10 +259,13 @@ async function reannounceAttachedTabs() {
|
||||
for (const [tabId, tab] of tabs.entries()) {
|
||||
if (tab.state !== 'connected' || !tab.sessionId || !tab.targetId) continue
|
||||
|
||||
// Retry once here as well; reconnect races can briefly make an otherwise
|
||||
// healthy tab look unavailable.
|
||||
const valid = await validateAttachedTab(tabId)
|
||||
if (!valid) {
|
||||
// Verify debugger is still attached.
|
||||
try {
|
||||
await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
|
||||
expression: '1',
|
||||
returnByValue: true,
|
||||
})
|
||||
} catch {
|
||||
tabs.delete(tabId)
|
||||
if (tab.sessionId) tabBySession.delete(tab.sessionId)
|
||||
setBadge(tabId, 'off')
|
||||
@@ -705,11 +672,6 @@ async function handleForwardCdpCommand(msg) {
|
||||
const toClose = target ? getTabByTargetId(target) : tabId
|
||||
if (!toClose) return { success: false }
|
||||
try {
|
||||
const allTabs = await chrome.tabs.query({})
|
||||
if (isLastRemainingTab(allTabs, toClose)) {
|
||||
console.warn('Refusing to close the last tab: this would kill the browser process')
|
||||
return { success: false, error: 'Cannot close the last tab' }
|
||||
}
|
||||
await chrome.tabs.remove(toClose)
|
||||
} catch {
|
||||
return { success: false }
|
||||
|
||||
@@ -1,299 +0,0 @@
|
||||
---
|
||||
summary: "Refactor clusters with highest LOC reduction potential"
|
||||
read_when:
|
||||
- You want to reduce total LOC without changing behavior
|
||||
- You are choosing the next dedupe or extraction pass
|
||||
title: "Refactor Cluster Backlog"
|
||||
---
|
||||
|
||||
# Refactor Cluster Backlog
|
||||
|
||||
Ranked by likely LOC reduction, safety, and breadth.
|
||||
|
||||
## 1. Channel plugin config and security scaffolding
|
||||
|
||||
Highest-value cluster.
|
||||
|
||||
Repeated shapes across many channel plugins:
|
||||
|
||||
- `config.listAccountIds`
|
||||
- `config.resolveAccount`
|
||||
- `config.defaultAccountId`
|
||||
- `config.setAccountEnabled`
|
||||
- `config.deleteAccount`
|
||||
- `config.describeAccount`
|
||||
- `security.resolveDmPolicy`
|
||||
|
||||
Strong examples:
|
||||
|
||||
- `extensions/telegram/src/channel.ts`
|
||||
- `extensions/googlechat/src/channel.ts`
|
||||
- `extensions/slack/src/channel.ts`
|
||||
- `extensions/discord/src/channel.ts`
|
||||
- `extensions/matrix/src/channel.ts`
|
||||
- `extensions/irc/src/channel.ts`
|
||||
- `extensions/signal/src/channel.ts`
|
||||
- `extensions/mattermost/src/channel.ts`
|
||||
|
||||
Likely extraction shape:
|
||||
|
||||
- `buildChannelConfigAdapter(...)`
|
||||
- `buildMultiAccountConfigAdapter(...)`
|
||||
- `buildDmSecurityAdapter(...)`
|
||||
|
||||
Expected savings:
|
||||
|
||||
- ~250-450 LOC
|
||||
|
||||
Risk:
|
||||
|
||||
- Medium. Each channel has slightly different `isConfigured`, warnings, and normalization.
|
||||
|
||||
## 2. Extension runtime singleton boilerplate
|
||||
|
||||
Very safe.
|
||||
|
||||
Nearly every extension has the same runtime holder:
|
||||
|
||||
- `let runtime: PluginRuntime | null = null`
|
||||
- `setXRuntime`
|
||||
- `getXRuntime`
|
||||
|
||||
Strong examples:
|
||||
|
||||
- `extensions/telegram/src/runtime.ts`
|
||||
- `extensions/matrix/src/runtime.ts`
|
||||
- `extensions/slack/src/runtime.ts`
|
||||
- `extensions/discord/src/runtime.ts`
|
||||
- `extensions/whatsapp/src/runtime.ts`
|
||||
- `extensions/imessage/src/runtime.ts`
|
||||
- `extensions/twitch/src/runtime.ts`
|
||||
|
||||
Special-case variants:
|
||||
|
||||
- `extensions/bluebubbles/src/runtime.ts`
|
||||
- `extensions/line/src/runtime.ts`
|
||||
- `extensions/synology-chat/src/runtime.ts`
|
||||
|
||||
Likely extraction shape:
|
||||
|
||||
- `createPluginRuntimeStore<T>(errorMessage)`
|
||||
|
||||
Expected savings:
|
||||
|
||||
- ~180-260 LOC
|
||||
|
||||
Risk:
|
||||
|
||||
- Low
|
||||
|
||||
## 3. Onboarding prompt and config-patch steps
|
||||
|
||||
Large surface area.
|
||||
|
||||
Many onboarding files repeat:
|
||||
|
||||
- resolve account id
|
||||
- prompt allowlist entries
|
||||
- merge allowFrom
|
||||
- set DM policy
|
||||
- prompt secrets
|
||||
- patch top-level vs account-scoped config
|
||||
|
||||
Strong examples:
|
||||
|
||||
- `extensions/bluebubbles/src/onboarding.ts`
|
||||
- `extensions/googlechat/src/onboarding.ts`
|
||||
- `extensions/msteams/src/onboarding.ts`
|
||||
- `extensions/zalo/src/onboarding.ts`
|
||||
- `extensions/zalouser/src/onboarding.ts`
|
||||
- `extensions/nextcloud-talk/src/onboarding.ts`
|
||||
- `extensions/matrix/src/onboarding.ts`
|
||||
- `extensions/irc/src/onboarding.ts`
|
||||
|
||||
Existing helper seam:
|
||||
|
||||
- `src/channels/plugins/onboarding/helpers.ts`
|
||||
|
||||
Likely extraction shape:
|
||||
|
||||
- `promptAllowFromList(...)`
|
||||
- `buildDmPolicyAdapter(...)`
|
||||
- `applyScopedAccountPatch(...)`
|
||||
- `promptSecretFields(...)`
|
||||
|
||||
Expected savings:
|
||||
|
||||
- ~300-600 LOC
|
||||
|
||||
Risk:
|
||||
|
||||
- Medium. Easy to over-generalize; keep helpers narrow and composable.
|
||||
|
||||
## 4. Multi-account config-schema fragments
|
||||
|
||||
Repeated schema fragments across extensions.
|
||||
|
||||
Common patterns:
|
||||
|
||||
- `const allowFromEntry = z.union([z.string(), z.number()])`
|
||||
- account schema plus:
|
||||
- `accounts: z.object({}).catchall(accountSchema).optional()`
|
||||
- `defaultAccount: z.string().optional()`
|
||||
- repeated DM/group fields
|
||||
- repeated markdown/tool policy fields
|
||||
|
||||
Strong examples:
|
||||
|
||||
- `extensions/bluebubbles/src/config-schema.ts`
|
||||
- `extensions/zalo/src/config-schema.ts`
|
||||
- `extensions/zalouser/src/config-schema.ts`
|
||||
- `extensions/matrix/src/config-schema.ts`
|
||||
- `extensions/nostr/src/config-schema.ts`
|
||||
|
||||
Likely extraction shape:
|
||||
|
||||
- `AllowFromEntrySchema`
|
||||
- `buildMultiAccountChannelSchema(accountSchema)`
|
||||
- `buildCommonDmGroupFields(...)`
|
||||
|
||||
Expected savings:
|
||||
|
||||
- ~120-220 LOC
|
||||
|
||||
Risk:
|
||||
|
||||
- Low to medium. Some schemas are simple, some are special.
|
||||
|
||||
## 5. Webhook and monitor lifecycle startup
|
||||
|
||||
Good medium-value cluster.
|
||||
|
||||
Repeated `startAccount` / monitor setup patterns:
|
||||
|
||||
- resolve account
|
||||
- compute webhook path
|
||||
- log startup
|
||||
- start monitor
|
||||
- wait for abort
|
||||
- cleanup
|
||||
- status sink updates
|
||||
|
||||
Strong examples:
|
||||
|
||||
- `extensions/googlechat/src/channel.ts`
|
||||
- `extensions/bluebubbles/src/channel.ts`
|
||||
- `extensions/zalo/src/channel.ts`
|
||||
- `extensions/telegram/src/channel.ts`
|
||||
- `extensions/nextcloud-talk/src/channel.ts`
|
||||
|
||||
Existing helper seam:
|
||||
|
||||
- `src/plugin-sdk/channel-lifecycle.ts`
|
||||
|
||||
Likely extraction shape:
|
||||
|
||||
- helper for account monitor lifecycle
|
||||
- helper for webhook-backed account startup
|
||||
|
||||
Expected savings:
|
||||
|
||||
- ~150-300 LOC
|
||||
|
||||
Risk:
|
||||
|
||||
- Medium to high. Transport details diverge quickly.
|
||||
|
||||
## 6. Small exact-clone cleanup
|
||||
|
||||
Low-risk cleanup bucket.
|
||||
|
||||
Examples:
|
||||
|
||||
- duplicated gateway argv detection:
|
||||
- `src/infra/gateway-lock.ts`
|
||||
- `src/cli/daemon-cli/lifecycle.ts`
|
||||
- duplicated port diagnostics rendering:
|
||||
- `src/cli/daemon-cli/restart-health.ts`
|
||||
- duplicated session-key construction:
|
||||
- `src/web/auto-reply/monitor/broadcast.ts`
|
||||
|
||||
Expected savings:
|
||||
|
||||
- ~30-60 LOC
|
||||
|
||||
Risk:
|
||||
|
||||
- Low
|
||||
|
||||
## Test clusters
|
||||
|
||||
### LINE webhook event fixtures
|
||||
|
||||
Strong examples:
|
||||
|
||||
- `src/line/bot-handlers.test.ts`
|
||||
|
||||
Likely extraction:
|
||||
|
||||
- `makeLineEvent(...)`
|
||||
- `runLineEvent(...)`
|
||||
- `makeLineAccount(...)`
|
||||
|
||||
Expected savings:
|
||||
|
||||
- ~120-180 LOC
|
||||
|
||||
### Telegram native command auth matrix
|
||||
|
||||
Strong examples:
|
||||
|
||||
- `src/telegram/bot-native-commands.group-auth.test.ts`
|
||||
- `src/telegram/bot-native-commands.plugin-auth.test.ts`
|
||||
|
||||
Likely extraction:
|
||||
|
||||
- forum context builder
|
||||
- denied-message assertion helper
|
||||
- table-driven auth cases
|
||||
|
||||
Expected savings:
|
||||
|
||||
- ~80-140 LOC
|
||||
|
||||
### Zalo lifecycle setup
|
||||
|
||||
Strong examples:
|
||||
|
||||
- `extensions/zalo/src/monitor.lifecycle.test.ts`
|
||||
|
||||
Likely extraction:
|
||||
|
||||
- shared monitor setup harness
|
||||
|
||||
Expected savings:
|
||||
|
||||
- ~50-90 LOC
|
||||
|
||||
### Brave llm-context unsupported-option tests
|
||||
|
||||
Strong examples:
|
||||
|
||||
- `src/agents/tools/web-tools.enabled-defaults.test.ts`
|
||||
|
||||
Likely extraction:
|
||||
|
||||
- `it.each(...)` matrix
|
||||
|
||||
Expected savings:
|
||||
|
||||
- ~30-50 LOC
|
||||
|
||||
## Suggested order
|
||||
|
||||
1. Runtime singleton boilerplate
|
||||
2. Small exact-clone cleanup
|
||||
3. Config and security builder extraction
|
||||
4. Test-helper extraction
|
||||
5. Onboarding step extraction
|
||||
6. Monitor lifecycle helper extraction
|
||||
@@ -1,8 +1,9 @@
|
||||
import { AllowFromEntrySchema, buildCatchallMultiAccountChannelSchema } from "openclaw/plugin-sdk";
|
||||
import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import { z } from "zod";
|
||||
import { buildSecretInputSchema, hasConfiguredSecretInput } from "./secret-input.js";
|
||||
|
||||
const allowFromEntry = z.union([z.string(), z.number()]);
|
||||
|
||||
const bluebubblesActionSchema = z
|
||||
.object({
|
||||
reactions: z.boolean().default(true),
|
||||
@@ -33,8 +34,8 @@ const bluebubblesAccountSchema = z
|
||||
password: buildSecretInputSchema().optional(),
|
||||
webhookPath: z.string().optional(),
|
||||
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
|
||||
allowFrom: z.array(AllowFromEntrySchema).optional(),
|
||||
groupAllowFrom: z.array(AllowFromEntrySchema).optional(),
|
||||
allowFrom: z.array(allowFromEntry).optional(),
|
||||
groupAllowFrom: z.array(allowFromEntry).optional(),
|
||||
groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(),
|
||||
historyLimit: z.number().int().min(0).optional(),
|
||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||
@@ -59,8 +60,8 @@ const bluebubblesAccountSchema = z
|
||||
}
|
||||
});
|
||||
|
||||
export const BlueBubblesConfigSchema = buildCatchallMultiAccountChannelSchema(
|
||||
bluebubblesAccountSchema,
|
||||
).extend({
|
||||
export const BlueBubblesConfigSchema = bluebubblesAccountSchema.extend({
|
||||
accounts: z.object({}).catchall(bluebubblesAccountSchema).optional(),
|
||||
defaultAccount: z.string().optional(),
|
||||
actions: bluebubblesActionSchema,
|
||||
});
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/bluebubbles";
|
||||
|
||||
const runtimeStore = createPluginRuntimeStore<PluginRuntime>("BlueBubbles runtime not initialized");
|
||||
let runtime: PluginRuntime | null = null;
|
||||
type LegacyRuntimeLogShape = { log?: (message: string) => void };
|
||||
export const setBlueBubblesRuntime = runtimeStore.setRuntime;
|
||||
|
||||
export function setBlueBubblesRuntime(next: PluginRuntime): void {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function clearBlueBubblesRuntime(): void {
|
||||
runtimeStore.clearRuntime();
|
||||
runtime = null;
|
||||
}
|
||||
|
||||
export function tryGetBlueBubblesRuntime(): PluginRuntime | null {
|
||||
return runtimeStore.tryGetRuntime();
|
||||
return runtime;
|
||||
}
|
||||
|
||||
export function getBlueBubblesRuntime(): PluginRuntime {
|
||||
return runtimeStore.getRuntime();
|
||||
if (!runtime) {
|
||||
throw new Error("BlueBubbles runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
||||
export function warnBlueBubbles(message: string): void {
|
||||
const formatted = `[bluebubbles] ${message}`;
|
||||
// Backward-compatible with tests/legacy injections that pass { log }.
|
||||
const log = (runtimeStore.tryGetRuntime() as unknown as LegacyRuntimeLogShape | null)?.log;
|
||||
const log = (runtime as unknown as LegacyRuntimeLogShape | null)?.log;
|
||||
if (typeof log === "function") {
|
||||
log(formatted);
|
||||
return;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
buildAccountScopedDmSecurityPolicy,
|
||||
collectOpenProviderGroupPolicyWarnings,
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
collectDiscordAuditChannelIds,
|
||||
collectDiscordStatusIssues,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
deleteAccountFromConfigSection,
|
||||
discordOnboardingAdapter,
|
||||
DiscordConfigSchema,
|
||||
getChatChannelMeta,
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
resolveDefaultDiscordAccountId,
|
||||
resolveDiscordGroupRequireMention,
|
||||
resolveDiscordGroupToolPolicy,
|
||||
setAccountEnabledInConfigSection,
|
||||
type ChannelMessageActionAdapter,
|
||||
type ChannelPlugin,
|
||||
type ResolvedDiscordAccount,
|
||||
@@ -62,15 +63,6 @@ const discordConfigAccessors = createScopedAccountConfigAccessors({
|
||||
resolveDefaultTo: (account: ResolvedDiscordAccount) => account.config.defaultTo,
|
||||
});
|
||||
|
||||
const discordConfigBase = createScopedChannelConfigBase({
|
||||
sectionKey: "discord",
|
||||
listAccountIds: listDiscordAccountIds,
|
||||
resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }),
|
||||
inspectAccount: (cfg, accountId) => inspectDiscordAccount({ cfg, accountId }),
|
||||
defaultAccountId: resolveDefaultDiscordAccountId,
|
||||
clearBaseFields: ["token", "name"],
|
||||
});
|
||||
|
||||
export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
id: "discord",
|
||||
meta: {
|
||||
@@ -101,7 +93,25 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
reload: { configPrefixes: ["channels.discord"] },
|
||||
configSchema: buildChannelConfigSchema(DiscordConfigSchema),
|
||||
config: {
|
||||
...discordConfigBase,
|
||||
listAccountIds: (cfg) => listDiscordAccountIds(cfg),
|
||||
resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }),
|
||||
inspectAccount: (cfg, accountId) => inspectDiscordAccount({ cfg, accountId }),
|
||||
defaultAccountId: (cfg) => resolveDefaultDiscordAccountId(cfg),
|
||||
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
||||
setAccountEnabledInConfigSection({
|
||||
cfg,
|
||||
sectionKey: "discord",
|
||||
accountId,
|
||||
enabled,
|
||||
allowTopLevel: true,
|
||||
}),
|
||||
deleteAccount: ({ cfg, accountId }) =>
|
||||
deleteAccountFromConfigSection({
|
||||
cfg,
|
||||
sectionKey: "discord",
|
||||
accountId,
|
||||
clearBaseFields: ["token", "name"],
|
||||
}),
|
||||
isConfigured: (account) => Boolean(account.token?.trim()),
|
||||
describeAccount: (account) => ({
|
||||
accountId: account.accountId,
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/discord";
|
||||
|
||||
const { setRuntime: setDiscordRuntime, getRuntime: getDiscordRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("Discord runtime not initialized");
|
||||
export { getDiscordRuntime, setDiscordRuntime };
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setDiscordRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getDiscordRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Discord runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/feishu";
|
||||
|
||||
const { setRuntime: setFeishuRuntime, getRuntime: getFeishuRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("Feishu runtime not initialized");
|
||||
export { getFeishuRuntime, setFeishuRuntime };
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setFeishuRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getFeishuRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Feishu runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
buildAccountScopedDmSecurityPolicy,
|
||||
buildOpenGroupPolicyConfigureRouteAllowlistWarning,
|
||||
@@ -12,6 +11,7 @@ import {
|
||||
buildComputedAccountStatusSnapshot,
|
||||
buildChannelConfigSchema,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
deleteAccountFromConfigSection,
|
||||
getChatChannelMeta,
|
||||
listDirectoryGroupEntriesFromMapKeys,
|
||||
listDirectoryUserEntriesFromAllowFrom,
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
resolveChannelMediaMaxBytes,
|
||||
resolveGoogleChatGroupRequireMention,
|
||||
setAccountEnabledInConfigSection,
|
||||
type ChannelDock,
|
||||
type ChannelMessageActionAdapter,
|
||||
type ChannelPlugin,
|
||||
@@ -67,23 +68,6 @@ const googleChatConfigAccessors = createScopedAccountConfigAccessors({
|
||||
resolveDefaultTo: (account: ResolvedGoogleChatAccount) => account.config.defaultTo,
|
||||
});
|
||||
|
||||
const googleChatConfigBase = createScopedChannelConfigBase<ResolvedGoogleChatAccount>({
|
||||
sectionKey: "googlechat",
|
||||
listAccountIds: listGoogleChatAccountIds,
|
||||
resolveAccount: (cfg, accountId) => resolveGoogleChatAccount({ cfg, accountId }),
|
||||
defaultAccountId: resolveDefaultGoogleChatAccountId,
|
||||
clearBaseFields: [
|
||||
"serviceAccount",
|
||||
"serviceAccountFile",
|
||||
"audienceType",
|
||||
"audience",
|
||||
"webhookPath",
|
||||
"webhookUrl",
|
||||
"botUser",
|
||||
"name",
|
||||
],
|
||||
});
|
||||
|
||||
export const googlechatDock: ChannelDock = {
|
||||
id: "googlechat",
|
||||
capabilities: {
|
||||
@@ -158,7 +142,33 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
|
||||
reload: { configPrefixes: ["channels.googlechat"] },
|
||||
configSchema: buildChannelConfigSchema(GoogleChatConfigSchema),
|
||||
config: {
|
||||
...googleChatConfigBase,
|
||||
listAccountIds: (cfg) => listGoogleChatAccountIds(cfg),
|
||||
resolveAccount: (cfg, accountId) => resolveGoogleChatAccount({ cfg: cfg, accountId }),
|
||||
defaultAccountId: (cfg) => resolveDefaultGoogleChatAccountId(cfg),
|
||||
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
||||
setAccountEnabledInConfigSection({
|
||||
cfg: cfg,
|
||||
sectionKey: "googlechat",
|
||||
accountId,
|
||||
enabled,
|
||||
allowTopLevel: true,
|
||||
}),
|
||||
deleteAccount: ({ cfg, accountId }) =>
|
||||
deleteAccountFromConfigSection({
|
||||
cfg: cfg,
|
||||
sectionKey: "googlechat",
|
||||
accountId,
|
||||
clearBaseFields: [
|
||||
"serviceAccount",
|
||||
"serviceAccountFile",
|
||||
"audienceType",
|
||||
"audience",
|
||||
"webhookPath",
|
||||
"webhookUrl",
|
||||
"botUser",
|
||||
"name",
|
||||
],
|
||||
}),
|
||||
isConfigured: (account) => account.credentialSource !== "none",
|
||||
describeAccount: (account) => ({
|
||||
accountId: account.accountId,
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/googlechat";
|
||||
|
||||
const { setRuntime: setGoogleChatRuntime, getRuntime: getGoogleChatRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("Google Chat runtime not initialized");
|
||||
export { getGoogleChatRuntime, setGoogleChatRuntime };
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setGoogleChatRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getGoogleChatRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Google Chat runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/imessage";
|
||||
|
||||
const { setRuntime: setIMessageRuntime, getRuntime: getIMessageRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("iMessage runtime not initialized");
|
||||
export { getIMessageRuntime, setIMessageRuntime };
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setIMessageRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getIMessageRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("iMessage runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/irc";
|
||||
|
||||
const { setRuntime: setIrcRuntime, getRuntime: getIrcRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("IRC runtime not initialized");
|
||||
export { getIrcRuntime, setIrcRuntime };
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setIrcRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getIrcRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("IRC runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/line";
|
||||
|
||||
const { setRuntime: setLineRuntime, getRuntime: getLineRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("LINE runtime not initialized - plugin not registered");
|
||||
export { getLineRuntime, setLineRuntime };
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setLineRuntime(r: PluginRuntime): void {
|
||||
runtime = r;
|
||||
}
|
||||
|
||||
export function getLineRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("LINE runtime not initialized - plugin not registered");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/matrix";
|
||||
|
||||
const { setRuntime: setMatrixRuntime, getRuntime: getMatrixRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("Matrix runtime not initialized");
|
||||
export { getMatrixRuntime, setMatrixRuntime };
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setMatrixRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getMatrixRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Matrix runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/mattermost";
|
||||
|
||||
const { setRuntime: setMattermostRuntime, getRuntime: getMattermostRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("Mattermost runtime not initialized");
|
||||
export { getMattermostRuntime, setMattermostRuntime };
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setMattermostRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getMattermostRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Mattermost runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/msteams";
|
||||
|
||||
const { setRuntime: setMSTeamsRuntime, getRuntime: getMSTeamsRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("MSTeams runtime not initialized");
|
||||
export { getMSTeamsRuntime, setMSTeamsRuntime };
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setMSTeamsRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getMSTeamsRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("MSTeams runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/nextcloud-talk";
|
||||
|
||||
const { setRuntime: setNextcloudTalkRuntime, getRuntime: getNextcloudTalkRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("Nextcloud Talk runtime not initialized");
|
||||
export { getNextcloudTalkRuntime, setNextcloudTalkRuntime };
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setNextcloudTalkRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getNextcloudTalkRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Nextcloud Talk runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/nostr";
|
||||
|
||||
const { setRuntime: setNostrRuntime, getRuntime: getNostrRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("Nostr runtime not initialized");
|
||||
export { getNostrRuntime, setNostrRuntime };
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setNostrRuntime(next: PluginRuntime): void {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getNostrRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Nostr runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/signal";
|
||||
|
||||
const { setRuntime: setSignalRuntime, getRuntime: getSignalRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("Signal runtime not initialized");
|
||||
export { getSignalRuntime, setSignalRuntime };
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setSignalRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getSignalRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Signal runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
buildAccountScopedDmSecurityPolicy,
|
||||
collectOpenProviderGroupPolicyWarnings,
|
||||
@@ -11,6 +10,7 @@ import {
|
||||
buildComputedAccountStatusSnapshot,
|
||||
buildChannelConfigSchema,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
deleteAccountFromConfigSection,
|
||||
extractSlackToolSend,
|
||||
getChatChannelMeta,
|
||||
handleSlackMessageAction,
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
resolveSlackGroupRequireMention,
|
||||
resolveSlackGroupToolPolicy,
|
||||
buildSlackThreadingToolContext,
|
||||
setAccountEnabledInConfigSection,
|
||||
slackOnboardingAdapter,
|
||||
SlackConfigSchema,
|
||||
type ChannelPlugin,
|
||||
@@ -95,15 +96,6 @@ const slackConfigAccessors = createScopedAccountConfigAccessors({
|
||||
resolveDefaultTo: (account: ResolvedSlackAccount) => account.config.defaultTo,
|
||||
});
|
||||
|
||||
const slackConfigBase = createScopedChannelConfigBase({
|
||||
sectionKey: "slack",
|
||||
listAccountIds: listSlackAccountIds,
|
||||
resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }),
|
||||
inspectAccount: (cfg, accountId) => inspectSlackAccount({ cfg, accountId }),
|
||||
defaultAccountId: resolveDefaultSlackAccountId,
|
||||
clearBaseFields: ["botToken", "appToken", "name"],
|
||||
});
|
||||
|
||||
export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
id: "slack",
|
||||
meta: {
|
||||
@@ -152,7 +144,25 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
reload: { configPrefixes: ["channels.slack"] },
|
||||
configSchema: buildChannelConfigSchema(SlackConfigSchema),
|
||||
config: {
|
||||
...slackConfigBase,
|
||||
listAccountIds: (cfg) => listSlackAccountIds(cfg),
|
||||
resolveAccount: (cfg, accountId) => resolveSlackAccount({ cfg, accountId }),
|
||||
inspectAccount: (cfg, accountId) => inspectSlackAccount({ cfg, accountId }),
|
||||
defaultAccountId: (cfg) => resolveDefaultSlackAccountId(cfg),
|
||||
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
||||
setAccountEnabledInConfigSection({
|
||||
cfg,
|
||||
sectionKey: "slack",
|
||||
accountId,
|
||||
enabled,
|
||||
allowTopLevel: true,
|
||||
}),
|
||||
deleteAccount: ({ cfg, accountId }) =>
|
||||
deleteAccountFromConfigSection({
|
||||
cfg,
|
||||
sectionKey: "slack",
|
||||
accountId,
|
||||
clearBaseFields: ["botToken", "appToken", "name"],
|
||||
}),
|
||||
isConfigured: (account) => isSlackAccountConfigured(account),
|
||||
describeAccount: (account) => ({
|
||||
accountId: account.accountId,
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/slack";
|
||||
|
||||
const { setRuntime: setSlackRuntime, getRuntime: getSlackRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("Slack runtime not initialized");
|
||||
export { getSlackRuntime, setSlackRuntime };
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setSlackRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getSlackRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Slack runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
/**
|
||||
* Plugin runtime singleton.
|
||||
* Stores the PluginRuntime from api.runtime (set during register()).
|
||||
* Used by channel.ts to access dispatch functions.
|
||||
*/
|
||||
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/synology-chat";
|
||||
|
||||
const { setRuntime: setSynologyRuntime, getRuntime: getSynologyRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>(
|
||||
"Synology Chat runtime not initialized - plugin not registered",
|
||||
);
|
||||
export { getSynologyRuntime, setSynologyRuntime };
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setSynologyRuntime(r: PluginRuntime): void {
|
||||
runtime = r;
|
||||
}
|
||||
|
||||
export function getSynologyRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Synology Chat runtime not initialized - plugin not registered");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
collectAllowlistProviderGroupPolicyWarnings,
|
||||
buildAccountScopedDmSecurityPolicy,
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
clearAccountEntryFields,
|
||||
collectTelegramStatusIssues,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
deleteAccountFromConfigSection,
|
||||
getChatChannelMeta,
|
||||
inspectTelegramAccount,
|
||||
listTelegramAccountIds,
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
resolveTelegramAccount,
|
||||
resolveTelegramGroupRequireMention,
|
||||
resolveTelegramGroupToolPolicy,
|
||||
setAccountEnabledInConfigSection,
|
||||
telegramOnboardingAdapter,
|
||||
TelegramConfigSchema,
|
||||
type ChannelMessageActionAdapter,
|
||||
@@ -99,15 +100,6 @@ const telegramConfigAccessors = createScopedAccountConfigAccessors({
|
||||
resolveDefaultTo: (account: ResolvedTelegramAccount) => account.config.defaultTo,
|
||||
});
|
||||
|
||||
const telegramConfigBase = createScopedChannelConfigBase<ResolvedTelegramAccount>({
|
||||
sectionKey: "telegram",
|
||||
listAccountIds: listTelegramAccountIds,
|
||||
resolveAccount: (cfg, accountId) => resolveTelegramAccount({ cfg, accountId }),
|
||||
inspectAccount: (cfg, accountId) => inspectTelegramAccount({ cfg, accountId }),
|
||||
defaultAccountId: resolveDefaultTelegramAccountId,
|
||||
clearBaseFields: ["botToken", "tokenFile", "name"],
|
||||
});
|
||||
|
||||
export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProbe> = {
|
||||
id: "telegram",
|
||||
meta: {
|
||||
@@ -144,7 +136,25 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
|
||||
reload: { configPrefixes: ["channels.telegram"] },
|
||||
configSchema: buildChannelConfigSchema(TelegramConfigSchema),
|
||||
config: {
|
||||
...telegramConfigBase,
|
||||
listAccountIds: (cfg) => listTelegramAccountIds(cfg),
|
||||
resolveAccount: (cfg, accountId) => resolveTelegramAccount({ cfg, accountId }),
|
||||
inspectAccount: (cfg, accountId) => inspectTelegramAccount({ cfg, accountId }),
|
||||
defaultAccountId: (cfg) => resolveDefaultTelegramAccountId(cfg),
|
||||
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
||||
setAccountEnabledInConfigSection({
|
||||
cfg,
|
||||
sectionKey: "telegram",
|
||||
accountId,
|
||||
enabled,
|
||||
allowTopLevel: true,
|
||||
}),
|
||||
deleteAccount: ({ cfg, accountId }) =>
|
||||
deleteAccountFromConfigSection({
|
||||
cfg,
|
||||
sectionKey: "telegram",
|
||||
accountId,
|
||||
clearBaseFields: ["botToken", "tokenFile", "name"],
|
||||
}),
|
||||
isConfigured: (account, cfg) => {
|
||||
if (!account.token?.trim()) {
|
||||
return false;
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/telegram";
|
||||
|
||||
const { setRuntime: setTelegramRuntime, getRuntime: getTelegramRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("Telegram runtime not initialized");
|
||||
export { getTelegramRuntime, setTelegramRuntime };
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setTelegramRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getTelegramRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Telegram runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/tlon";
|
||||
|
||||
const { setRuntime: setTlonRuntime, getRuntime: getTlonRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("Tlon runtime not initialized");
|
||||
export { getTlonRuntime, setTlonRuntime };
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setTlonRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getTlonRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Tlon runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/twitch";
|
||||
|
||||
const { setRuntime: setTwitchRuntime, getRuntime: getTwitchRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("Twitch runtime not initialized");
|
||||
export { getTwitchRuntime, setTwitchRuntime };
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setTwitchRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getTwitchRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Twitch runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/whatsapp";
|
||||
|
||||
const { setRuntime: setWhatsAppRuntime, getRuntime: getWhatsAppRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("WhatsApp runtime not initialized");
|
||||
export { getWhatsAppRuntime, setWhatsAppRuntime };
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setWhatsAppRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getWhatsAppRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("WhatsApp runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { AllowFromEntrySchema, buildCatchallMultiAccountChannelSchema } from "openclaw/plugin-sdk";
|
||||
import { MarkdownConfigSchema } from "openclaw/plugin-sdk/zalo";
|
||||
import { z } from "zod";
|
||||
import { buildSecretInputSchema } from "./secret-input.js";
|
||||
|
||||
const allowFromEntry = z.union([z.string(), z.number()]);
|
||||
|
||||
const zaloAccountSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
@@ -13,12 +14,15 @@ const zaloAccountSchema = z.object({
|
||||
webhookSecret: buildSecretInputSchema().optional(),
|
||||
webhookPath: z.string().optional(),
|
||||
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
|
||||
allowFrom: z.array(AllowFromEntrySchema).optional(),
|
||||
allowFrom: z.array(allowFromEntry).optional(),
|
||||
groupPolicy: z.enum(["disabled", "allowlist", "open"]).optional(),
|
||||
groupAllowFrom: z.array(AllowFromEntrySchema).optional(),
|
||||
groupAllowFrom: z.array(allowFromEntry).optional(),
|
||||
mediaMaxMb: z.number().optional(),
|
||||
proxy: z.string().optional(),
|
||||
responsePrefix: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ZaloConfigSchema = buildCatchallMultiAccountChannelSchema(zaloAccountSchema);
|
||||
export const ZaloConfigSchema = zaloAccountSchema.extend({
|
||||
accounts: z.object({}).catchall(zaloAccountSchema).optional(),
|
||||
defaultAccount: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/zalo";
|
||||
|
||||
const { setRuntime: setZaloRuntime, getRuntime: getZaloRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("Zalo runtime not initialized");
|
||||
export { getZaloRuntime, setZaloRuntime };
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setZaloRuntime(next: PluginRuntime): void {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getZaloRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Zalo runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { AllowFromEntrySchema, buildCatchallMultiAccountChannelSchema } from "openclaw/plugin-sdk";
|
||||
import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/zalouser";
|
||||
import { z } from "zod";
|
||||
|
||||
const allowFromEntry = z.union([z.string(), z.number()]);
|
||||
|
||||
const groupConfigSchema = z.object({
|
||||
allow: z.boolean().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
@@ -15,13 +16,16 @@ const zalouserAccountSchema = z.object({
|
||||
markdown: MarkdownConfigSchema,
|
||||
profile: z.string().optional(),
|
||||
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
|
||||
allowFrom: z.array(AllowFromEntrySchema).optional(),
|
||||
allowFrom: z.array(allowFromEntry).optional(),
|
||||
historyLimit: z.number().int().min(0).optional(),
|
||||
groupAllowFrom: z.array(AllowFromEntrySchema).optional(),
|
||||
groupAllowFrom: z.array(allowFromEntry).optional(),
|
||||
groupPolicy: z.enum(["disabled", "allowlist", "open"]).optional(),
|
||||
groups: z.object({}).catchall(groupConfigSchema).optional(),
|
||||
messagePrefix: z.string().optional(),
|
||||
responsePrefix: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ZalouserConfigSchema = buildCatchallMultiAccountChannelSchema(zalouserAccountSchema);
|
||||
export const ZalouserConfigSchema = zalouserAccountSchema.extend({
|
||||
accounts: z.object({}).catchall(zalouserAccountSchema).optional(),
|
||||
defaultAccount: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/zalouser";
|
||||
|
||||
const { setRuntime: setZalouserRuntime, getRuntime: getZalouserRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>("Zalouser runtime not initialized");
|
||||
export { getZalouserRuntime, setZalouserRuntime };
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setZalouserRuntime(next: PluginRuntime): void {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getZalouserRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Zalouser runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM node:22-bookworm-slim@sha256:3cfe526ec8dd62013b8843e8e5d4877e297b886e5aace4a59fec25dc20736e45
|
||||
|
||||
RUN --mount=type=cache,id=openclaw-cleanup-smoke-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=openclaw-cleanup-smoke-apt-lists,target=/var/lib/apt,sharing=locked \
|
||||
apt-get update \
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
bash \
|
||||
ca-certificates \
|
||||
git
|
||||
git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /repo
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \
|
||||
corepack enable \
|
||||
RUN corepack enable \
|
||||
&& pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM node:22-bookworm-slim@sha256:3cfe526ec8dd62013b8843e8e5d4877e297b886e5aace4a59fec25dc20736e45
|
||||
|
||||
RUN --mount=type=cache,id=openclaw-install-sh-e2e-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=openclaw-install-sh-e2e-apt-lists,target=/var/lib/apt,sharing=locked \
|
||||
apt-get update \
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
bash \
|
||||
ca-certificates \
|
||||
curl \
|
||||
git
|
||||
git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY install-sh-common/version-parse.sh /usr/local/install-sh-common/version-parse.sh
|
||||
COPY --chmod=755 run.sh /usr/local/bin/openclaw-install-e2e
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM ubuntu:24.04@sha256:cd1dba651b3080c3686ecf4e3c4220f026b521fb76978881737d24f200828b2b
|
||||
|
||||
RUN --mount=type=cache,id=openclaw-install-sh-nonroot-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=openclaw-install-sh-nonroot-apt-lists,target=/var/lib/apt,sharing=locked \
|
||||
set -eux; \
|
||||
RUN set -eux; \
|
||||
for attempt in 1 2 3; do \
|
||||
if apt-get update -o Acquire::Retries=3; then break; fi; \
|
||||
echo "apt-get update failed (attempt ${attempt})" >&2; \
|
||||
@@ -18,7 +14,8 @@ RUN --mount=type=cache,id=openclaw-install-sh-nonroot-apt-cache,target=/var/cach
|
||||
g++ \
|
||||
make \
|
||||
python3 \
|
||||
sudo
|
||||
sudo \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN useradd -m -s /bin/bash app \
|
||||
&& echo "app ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/app
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM node:22-bookworm-slim@sha256:3cfe526ec8dd62013b8843e8e5d4877e297b886e5aace4a59fec25dc20736e45
|
||||
|
||||
RUN --mount=type=cache,id=openclaw-install-sh-smoke-apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=openclaw-install-sh-smoke-apt-lists,target=/var/lib/apt,sharing=locked \
|
||||
set -eux; \
|
||||
RUN set -eux; \
|
||||
for attempt in 1 2 3; do \
|
||||
if apt-get update -o Acquire::Retries=3; then break; fi; \
|
||||
echo "apt-get update failed (attempt ${attempt})" >&2; \
|
||||
@@ -19,7 +15,8 @@ RUN --mount=type=cache,id=openclaw-install-sh-smoke-apt-cache,target=/var/cache/
|
||||
g++ \
|
||||
make \
|
||||
python3 \
|
||||
sudo
|
||||
sudo \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY install-sh-common/cli-verify.sh /usr/local/install-sh-common/cli-verify.sh
|
||||
COPY install-sh-common/version-parse.sh /usr/local/install-sh-common/version-parse.sh
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM node:22-bookworm@sha256:cd7bcd2e7a1e6f72052feb023c7f6b722205d3fcab7bbcbd2d1bfdab10b1e935
|
||||
|
||||
RUN corepack enable
|
||||
@@ -8,26 +6,20 @@ WORKDIR /app
|
||||
|
||||
ENV NODE_OPTIONS="--disable-warning=ExperimentalWarning"
|
||||
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY ui/package.json ./ui/package.json
|
||||
COPY extensions/memory-core/package.json ./extensions/memory-core/package.json
|
||||
COPY patches ./patches
|
||||
|
||||
RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
COPY tsconfig.json tsconfig.plugin-sdk.dts.json tsdown.config.ts vitest.config.ts vitest.e2e.config.ts openclaw.mjs ./
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json tsconfig.plugin-sdk.dts.json tsdown.config.ts vitest.config.ts vitest.e2e.config.ts openclaw.mjs ./
|
||||
COPY src ./src
|
||||
COPY test ./test
|
||||
COPY scripts ./scripts
|
||||
COPY docs ./docs
|
||||
COPY skills ./skills
|
||||
COPY patches ./patches
|
||||
COPY ui ./ui
|
||||
COPY extensions/memory-core ./extensions/memory-core
|
||||
COPY vendor/a2ui/renderers/lit ./vendor/a2ui/renderers/lit
|
||||
COPY apps/shared/OpenClawKit/Sources/OpenClawKit/Resources ./apps/shared/OpenClawKit/Sources/OpenClawKit/Resources
|
||||
COPY apps/shared/OpenClawKit/Tools/CanvasA2UI ./apps/shared/OpenClawKit/Tools/CanvasA2UI
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
RUN pnpm build
|
||||
RUN pnpm ui:build
|
||||
|
||||
|
||||
@@ -1,23 +1,13 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM node:22-bookworm@sha256:cd7bcd2e7a1e6f72052feb023c7f6b722205d3fcab7bbcbd2d1bfdab10b1e935
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY ui/package.json ./ui/package.json
|
||||
COPY patches ./patches
|
||||
|
||||
# This image only exercises the root qrcode-terminal dependency path.
|
||||
# Keep the pre-install copy set limited to the manifests needed for root
|
||||
# workspace resolution so unrelated extension edits do not bust the layer.
|
||||
RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
RUN useradd --create-home --shell /bin/bash appuser \
|
||||
&& chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
@@ -10,9 +10,6 @@ BUN_INSTALL_DIR="${BUN_INSTALL_DIR:-/opt/bun}"
|
||||
INSTALL_BREW="${INSTALL_BREW:-1}"
|
||||
BREW_INSTALL_DIR="${BREW_INSTALL_DIR:-/home/linuxbrew/.linuxbrew}"
|
||||
FINAL_USER="${FINAL_USER:-sandbox}"
|
||||
OPENCLAW_DOCKER_BUILD_USE_BUILDX="${OPENCLAW_DOCKER_BUILD_USE_BUILDX:-0}"
|
||||
OPENCLAW_DOCKER_BUILD_CACHE_FROM="${OPENCLAW_DOCKER_BUILD_CACHE_FROM:-}"
|
||||
OPENCLAW_DOCKER_BUILD_CACHE_TO="${OPENCLAW_DOCKER_BUILD_CACHE_TO:-}"
|
||||
|
||||
if ! docker image inspect "${BASE_IMAGE}" >/dev/null 2>&1; then
|
||||
echo "Base image missing: ${BASE_IMAGE}"
|
||||
@@ -22,18 +19,7 @@ fi
|
||||
|
||||
echo "Building ${TARGET_IMAGE} with: ${PACKAGES}"
|
||||
|
||||
build_cmd=(docker build)
|
||||
if [ "${OPENCLAW_DOCKER_BUILD_USE_BUILDX}" = "1" ]; then
|
||||
build_cmd=(docker buildx build --load)
|
||||
if [ -n "${OPENCLAW_DOCKER_BUILD_CACHE_FROM}" ]; then
|
||||
build_cmd+=(--cache-from "${OPENCLAW_DOCKER_BUILD_CACHE_FROM}")
|
||||
fi
|
||||
if [ -n "${OPENCLAW_DOCKER_BUILD_CACHE_TO}" ]; then
|
||||
build_cmd+=(--cache-to "${OPENCLAW_DOCKER_BUILD_CACHE_TO}")
|
||||
fi
|
||||
fi
|
||||
|
||||
"${build_cmd[@]}" \
|
||||
docker build \
|
||||
-t "${TARGET_IMAGE}" \
|
||||
-f Dockerfile.sandbox-common \
|
||||
--build-arg BASE_IMAGE="${BASE_IMAGE}" \
|
||||
|
||||
@@ -2,7 +2,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
hookRunner,
|
||||
ensureRuntimePluginsLoaded,
|
||||
resolveModelMock,
|
||||
sessionCompactImpl,
|
||||
triggerInternalHook,
|
||||
@@ -13,7 +12,6 @@ const {
|
||||
runBeforeCompaction: vi.fn(),
|
||||
runAfterCompaction: vi.fn(),
|
||||
},
|
||||
ensureRuntimePluginsLoaded: vi.fn(),
|
||||
resolveModelMock: vi.fn(() => ({
|
||||
model: { provider: "openai", api: "responses", id: "fake", input: [] },
|
||||
error: null,
|
||||
@@ -34,10 +32,6 @@ vi.mock("../../plugins/hook-runner-global.js", () => ({
|
||||
getGlobalHookRunner: () => hookRunner,
|
||||
}));
|
||||
|
||||
vi.mock("../runtime-plugins.js", () => ({
|
||||
ensureRuntimePluginsLoaded,
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/internal-hooks.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../hooks/internal-hooks.js")>(
|
||||
"../../hooks/internal-hooks.js",
|
||||
@@ -260,7 +254,6 @@ const sessionHook = (action: string) =>
|
||||
|
||||
describe("compactEmbeddedPiSessionDirect hooks", () => {
|
||||
beforeEach(() => {
|
||||
ensureRuntimePluginsLoaded.mockReset();
|
||||
triggerInternalHook.mockClear();
|
||||
hookRunner.hasHooks.mockReset();
|
||||
hookRunner.runBeforeCompaction.mockReset();
|
||||
@@ -286,19 +279,6 @@ describe("compactEmbeddedPiSessionDirect hooks", () => {
|
||||
unregisterApiProviders(getCustomApiRegistrySourceId("ollama"));
|
||||
});
|
||||
|
||||
it("bootstraps runtime plugins with the resolved workspace", async () => {
|
||||
await compactEmbeddedPiSessionDirect({
|
||||
sessionId: "session-1",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
});
|
||||
|
||||
expect(ensureRuntimePluginsLoaded).toHaveBeenCalledWith({
|
||||
config: undefined,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
});
|
||||
});
|
||||
|
||||
it("emits internal + plugin compaction hooks with counts", async () => {
|
||||
hookRunner.hasHooks.mockReturnValue(true);
|
||||
let sanitizedCount = 0;
|
||||
|
||||
@@ -50,7 +50,6 @@ import {
|
||||
} from "../pi-embedded-helpers.js";
|
||||
import { createPreparedEmbeddedPiSettingsManager } from "../pi-project-settings.js";
|
||||
import { createOpenClawCodingTools } from "../pi-tools.js";
|
||||
import { ensureRuntimePluginsLoaded } from "../runtime-plugins.js";
|
||||
import { resolveSandboxContext } from "../sandbox.js";
|
||||
import { repairSessionFileIfNeeded } from "../session-file-repair.js";
|
||||
import { guardSessionManager } from "../session-tool-result-guard-wrapper.js";
|
||||
@@ -270,10 +269,6 @@ export async function compactEmbeddedPiSessionDirect(
|
||||
const maxAttempts = params.maxAttempts ?? 1;
|
||||
const runId = params.runId ?? params.sessionId;
|
||||
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
|
||||
ensureRuntimePluginsLoaded({
|
||||
config: params.config,
|
||||
workspaceDir: resolvedWorkspace,
|
||||
});
|
||||
const prevCwd = process.cwd();
|
||||
|
||||
// Resolve compaction model: prefer config override, then fall back to caller-supplied model
|
||||
@@ -915,10 +910,6 @@ export async function compactEmbeddedPiSession(
|
||||
params.enqueue ?? ((task, opts) => enqueueCommandInLane(globalLane, task, opts));
|
||||
return enqueueCommandInLane(sessionLane, () =>
|
||||
enqueueGlobal(async () => {
|
||||
ensureRuntimePluginsLoaded({
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
});
|
||||
ensureContextEnginesInitialized();
|
||||
const contextEngine = await resolveContextEngine(params.config);
|
||||
try {
|
||||
|
||||
@@ -54,7 +54,6 @@ import {
|
||||
pickFallbackThinkingLevel,
|
||||
type FailoverReason,
|
||||
} from "../pi-embedded-helpers.js";
|
||||
import { ensureRuntimePluginsLoaded } from "../runtime-plugins.js";
|
||||
import { derivePromptTokens, normalizeUsage, type UsageLike } from "../usage.js";
|
||||
import { redactRunIdentifier, resolveRunWorkspaceDir } from "../workspace-run.js";
|
||||
import { resolveGlobalLane, resolveSessionLane } from "./lanes.js";
|
||||
@@ -288,10 +287,6 @@ export async function runEmbeddedPiAgent(
|
||||
`[workspace-fallback] caller=runEmbeddedPiAgent reason=${workspaceResolution.fallbackReason} run=${params.runId} session=${redactedSessionId} sessionKey=${redactedSessionKey} agent=${workspaceResolution.agentId} workspace=${redactedWorkspace}`,
|
||||
);
|
||||
}
|
||||
ensureRuntimePluginsLoaded({
|
||||
config: params.config,
|
||||
workspaceDir: resolvedWorkspace,
|
||||
});
|
||||
const prevCwd = process.cwd();
|
||||
|
||||
let provider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER;
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
import "./run.overflow-compaction.mocks.shared.js";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const runtimePluginMocks = vi.hoisted(() => ({
|
||||
ensureRuntimePluginsLoaded: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../runtime-plugins.js", () => ({
|
||||
ensureRuntimePluginsLoaded: runtimePluginMocks.ensureRuntimePluginsLoaded,
|
||||
}));
|
||||
|
||||
import { runEmbeddedPiAgent } from "./run.js";
|
||||
import { runEmbeddedAttempt } from "./run/attempt.js";
|
||||
|
||||
@@ -19,32 +10,6 @@ describe("runEmbeddedPiAgent usage reporting", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("bootstraps runtime plugins with the resolved workspace before running", async () => {
|
||||
mockedRunEmbeddedAttempt.mockResolvedValueOnce({
|
||||
aborted: false,
|
||||
promptError: null,
|
||||
timedOut: false,
|
||||
sessionIdUsed: "test-session",
|
||||
assistantTexts: ["Response 1"],
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any);
|
||||
|
||||
await runEmbeddedPiAgent({
|
||||
sessionId: "test-session",
|
||||
sessionKey: "test-key",
|
||||
sessionFile: "/tmp/session.json",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
prompt: "hello",
|
||||
timeoutMs: 30000,
|
||||
runId: "run-plugin-bootstrap",
|
||||
});
|
||||
|
||||
expect(runtimePluginMocks.ensureRuntimePluginsLoaded).toHaveBeenCalledWith({
|
||||
config: undefined,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards sender identity fields into embedded attempts", async () => {
|
||||
mockedRunEmbeddedAttempt.mockResolvedValueOnce({
|
||||
aborted: false,
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { loadOpenClawPlugins } from "../plugins/loader.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
|
||||
export function ensureRuntimePluginsLoaded(params: {
|
||||
config?: OpenClawConfig;
|
||||
workspaceDir?: string | null;
|
||||
}): void {
|
||||
const workspaceDir =
|
||||
typeof params.workspaceDir === "string" && params.workspaceDir.trim()
|
||||
? resolveUserPath(params.workspaceDir)
|
||||
: undefined;
|
||||
|
||||
loadOpenClawPlugins({
|
||||
config: params.config,
|
||||
workspaceDir,
|
||||
});
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
ensureRuntimePluginsLoaded: vi.fn(),
|
||||
ensureContextEnginesInitialized: vi.fn(),
|
||||
resolveContextEngine: vi.fn(),
|
||||
onSubagentEnded: vi.fn(async () => {}),
|
||||
onAgentEvent: vi.fn(() => () => {}),
|
||||
persistSubagentRunsToDisk: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../config/config.js")>("../config/config.js");
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: vi.fn(() => ({})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../context-engine/init.js", () => ({
|
||||
ensureContextEnginesInitialized: mocks.ensureContextEnginesInitialized,
|
||||
}));
|
||||
|
||||
vi.mock("../context-engine/registry.js", () => ({
|
||||
resolveContextEngine: mocks.resolveContextEngine,
|
||||
}));
|
||||
|
||||
vi.mock("../infra/agent-events.js", () => ({
|
||||
onAgentEvent: mocks.onAgentEvent,
|
||||
}));
|
||||
|
||||
vi.mock("./runtime-plugins.js", () => ({
|
||||
ensureRuntimePluginsLoaded: mocks.ensureRuntimePluginsLoaded,
|
||||
}));
|
||||
|
||||
vi.mock("./subagent-registry-state.js", () => ({
|
||||
getSubagentRunsSnapshotForRead: vi.fn((runs: Map<string, unknown>) => new Map(runs)),
|
||||
persistSubagentRunsToDisk: mocks.persistSubagentRunsToDisk,
|
||||
restoreSubagentRunsFromDisk: vi.fn(() => 0),
|
||||
}));
|
||||
|
||||
vi.mock("./subagent-announce-queue.js", () => ({
|
||||
resetAnnounceQueuesForTests: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./timeout.js", () => ({
|
||||
resolveAgentTimeoutMs: vi.fn(() => 1_000),
|
||||
}));
|
||||
|
||||
import {
|
||||
registerSubagentRun,
|
||||
releaseSubagentRun,
|
||||
resetSubagentRegistryForTests,
|
||||
} from "./subagent-registry.js";
|
||||
|
||||
describe("subagent-registry context-engine bootstrap", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.resolveContextEngine.mockResolvedValue({
|
||||
onSubagentEnded: mocks.onSubagentEnded,
|
||||
});
|
||||
resetSubagentRegistryForTests({ persist: false });
|
||||
});
|
||||
|
||||
it("reloads runtime plugins with the spawned workspace before subagent end hooks", async () => {
|
||||
registerSubagentRun({
|
||||
runId: "run-1",
|
||||
childSessionKey: "agent:main:session:child",
|
||||
requesterSessionKey: "agent:main:session:parent",
|
||||
requesterDisplayKey: "parent",
|
||||
task: "task",
|
||||
cleanup: "keep",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
});
|
||||
|
||||
releaseSubagentRun("run-1");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(mocks.ensureRuntimePluginsLoaded).toHaveBeenCalledWith({
|
||||
config: {},
|
||||
workspaceDir: "/tmp/workspace",
|
||||
});
|
||||
});
|
||||
expect(mocks.ensureContextEnginesInitialized).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.onSubagentEnded).toHaveBeenCalledWith({
|
||||
childSessionKey: "agent:main:session:child",
|
||||
reason: "released",
|
||||
workspaceDir: "/tmp/workspace",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,6 @@ import { onAgentEvent } from "../infra/agent-events.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { type DeliveryContext, normalizeDeliveryContext } from "../utils/delivery-context.js";
|
||||
import { ensureRuntimePluginsLoaded } from "./runtime-plugins.js";
|
||||
import { resetAnnounceQueuesForTests } from "./subagent-announce-queue.js";
|
||||
import {
|
||||
captureSubagentCompletionReply,
|
||||
@@ -314,16 +313,10 @@ function schedulePendingLifecycleError(params: { runId: string; endedAt: number;
|
||||
async function notifyContextEngineSubagentEnded(params: {
|
||||
childSessionKey: string;
|
||||
reason: SubagentEndReason;
|
||||
workspaceDir?: string;
|
||||
}) {
|
||||
try {
|
||||
const cfg = loadConfig();
|
||||
ensureRuntimePluginsLoaded({
|
||||
config: cfg,
|
||||
workspaceDir: params.workspaceDir,
|
||||
});
|
||||
ensureContextEnginesInitialized();
|
||||
const engine = await resolveContextEngine(cfg);
|
||||
const engine = await resolveContextEngine(loadConfig());
|
||||
if (!engine.onSubagentEnded) {
|
||||
return;
|
||||
}
|
||||
@@ -721,7 +714,6 @@ async function sweepSubagentRuns() {
|
||||
void notifyContextEngineSubagentEnded({
|
||||
childSessionKey: entry.childSessionKey,
|
||||
reason: "swept",
|
||||
workspaceDir: entry.workspaceDir,
|
||||
});
|
||||
subagentRuns.delete(runId);
|
||||
mutated = true;
|
||||
@@ -971,7 +963,6 @@ function completeCleanupBookkeeping(params: {
|
||||
void notifyContextEngineSubagentEnded({
|
||||
childSessionKey: params.entry.childSessionKey,
|
||||
reason: "deleted",
|
||||
workspaceDir: params.entry.workspaceDir,
|
||||
});
|
||||
subagentRuns.delete(params.runId);
|
||||
persistSubagentRuns();
|
||||
@@ -981,7 +972,6 @@ function completeCleanupBookkeeping(params: {
|
||||
void notifyContextEngineSubagentEnded({
|
||||
childSessionKey: params.entry.childSessionKey,
|
||||
reason: "completed",
|
||||
workspaceDir: params.entry.workspaceDir,
|
||||
});
|
||||
params.entry.cleanupCompletedAt = params.completedAt;
|
||||
persistSubagentRuns();
|
||||
@@ -1153,7 +1143,6 @@ export function registerSubagentRun(params: {
|
||||
cleanup: "delete" | "keep";
|
||||
label?: string;
|
||||
model?: string;
|
||||
workspaceDir?: string;
|
||||
runTimeoutSeconds?: number;
|
||||
expectsCompletionMessage?: boolean;
|
||||
spawnMode?: "run" | "session";
|
||||
@@ -1182,7 +1171,6 @@ export function registerSubagentRun(params: {
|
||||
spawnMode,
|
||||
label: params.label,
|
||||
model: params.model,
|
||||
workspaceDir: params.workspaceDir,
|
||||
runTimeoutSeconds,
|
||||
createdAt: now,
|
||||
startedAt: now,
|
||||
@@ -1297,7 +1285,6 @@ export function releaseSubagentRun(runId: string) {
|
||||
void notifyContextEngineSubagentEnded({
|
||||
childSessionKey: entry.childSessionKey,
|
||||
reason: "released",
|
||||
workspaceDir: entry.workspaceDir,
|
||||
});
|
||||
}
|
||||
const didDelete = subagentRuns.delete(runId);
|
||||
|
||||
@@ -13,7 +13,6 @@ export type SubagentRunRecord = {
|
||||
cleanup: "delete" | "keep";
|
||||
label?: string;
|
||||
model?: string;
|
||||
workspaceDir?: string;
|
||||
runTimeoutSeconds?: number;
|
||||
spawnMode?: SpawnSubagentMode;
|
||||
createdAt: number;
|
||||
|
||||
@@ -650,7 +650,6 @@ export async function spawnSubagentDirect(
|
||||
cleanup,
|
||||
label: label || undefined,
|
||||
model: resolvedModel,
|
||||
workspaceDir: spawnedMetadata.workspaceDir,
|
||||
runTimeoutSeconds,
|
||||
expectsCompletionMessage,
|
||||
spawnMode,
|
||||
|
||||
@@ -112,19 +112,16 @@ export async function executeSnapshotAction(params: {
|
||||
}): Promise<AgentToolResult<unknown>> {
|
||||
const { input, baseUrl, profile, proxyRequest } = params;
|
||||
const snapshotDefaults = loadConfig().browser?.snapshotDefaults;
|
||||
const format: "ai" | "aria" | undefined =
|
||||
input.snapshotFormat === "ai" || input.snapshotFormat === "aria"
|
||||
? input.snapshotFormat
|
||||
: undefined;
|
||||
const mode: "efficient" | undefined =
|
||||
const format =
|
||||
input.snapshotFormat === "ai" || input.snapshotFormat === "aria" ? input.snapshotFormat : "ai";
|
||||
const mode =
|
||||
input.mode === "efficient"
|
||||
? "efficient"
|
||||
: format !== "aria" && snapshotDefaults?.mode === "efficient"
|
||||
: format === "ai" && snapshotDefaults?.mode === "efficient"
|
||||
? "efficient"
|
||||
: undefined;
|
||||
const labels = typeof input.labels === "boolean" ? input.labels : undefined;
|
||||
const refs: "aria" | "role" | undefined =
|
||||
input.refs === "aria" || input.refs === "role" ? input.refs : undefined;
|
||||
const refs = input.refs === "aria" || input.refs === "role" ? input.refs : undefined;
|
||||
const hasMaxChars = Object.hasOwn(input, "maxChars");
|
||||
const targetId = typeof input.targetId === "string" ? input.targetId.trim() : undefined;
|
||||
const limit =
|
||||
@@ -133,12 +130,6 @@ export async function executeSnapshotAction(params: {
|
||||
typeof input.maxChars === "number" && Number.isFinite(input.maxChars) && input.maxChars > 0
|
||||
? Math.floor(input.maxChars)
|
||||
: undefined;
|
||||
const interactive = typeof input.interactive === "boolean" ? input.interactive : undefined;
|
||||
const compact = typeof input.compact === "boolean" ? input.compact : undefined;
|
||||
const depth =
|
||||
typeof input.depth === "number" && Number.isFinite(input.depth) ? input.depth : undefined;
|
||||
const selector = typeof input.selector === "string" ? input.selector.trim() : undefined;
|
||||
const frame = typeof input.frame === "string" ? input.frame.trim() : undefined;
|
||||
const resolvedMaxChars =
|
||||
format === "ai"
|
||||
? hasMaxChars
|
||||
@@ -146,32 +137,46 @@ export async function executeSnapshotAction(params: {
|
||||
: mode === "efficient"
|
||||
? undefined
|
||||
: DEFAULT_AI_SNAPSHOT_MAX_CHARS
|
||||
: hasMaxChars
|
||||
? maxChars
|
||||
: undefined;
|
||||
const snapshotQuery = {
|
||||
...(format ? { format } : {}),
|
||||
targetId,
|
||||
limit,
|
||||
...(typeof resolvedMaxChars === "number" ? { maxChars: resolvedMaxChars } : {}),
|
||||
refs,
|
||||
interactive,
|
||||
compact,
|
||||
depth,
|
||||
selector,
|
||||
frame,
|
||||
labels,
|
||||
mode,
|
||||
};
|
||||
: undefined;
|
||||
const interactive = typeof input.interactive === "boolean" ? input.interactive : undefined;
|
||||
const compact = typeof input.compact === "boolean" ? input.compact : undefined;
|
||||
const depth =
|
||||
typeof input.depth === "number" && Number.isFinite(input.depth) ? input.depth : undefined;
|
||||
const selector = typeof input.selector === "string" ? input.selector.trim() : undefined;
|
||||
const frame = typeof input.frame === "string" ? input.frame.trim() : undefined;
|
||||
const snapshot = proxyRequest
|
||||
? ((await proxyRequest({
|
||||
method: "GET",
|
||||
path: "/snapshot",
|
||||
profile,
|
||||
query: snapshotQuery,
|
||||
query: {
|
||||
format,
|
||||
targetId,
|
||||
limit,
|
||||
...(typeof resolvedMaxChars === "number" ? { maxChars: resolvedMaxChars } : {}),
|
||||
refs,
|
||||
interactive,
|
||||
compact,
|
||||
depth,
|
||||
selector,
|
||||
frame,
|
||||
labels,
|
||||
mode,
|
||||
},
|
||||
})) as Awaited<ReturnType<typeof browserSnapshot>>)
|
||||
: await browserSnapshot(baseUrl, {
|
||||
...snapshotQuery,
|
||||
format,
|
||||
targetId,
|
||||
limit,
|
||||
...(typeof resolvedMaxChars === "number" ? { maxChars: resolvedMaxChars } : {}),
|
||||
refs,
|
||||
interactive,
|
||||
compact,
|
||||
depth,
|
||||
selector,
|
||||
frame,
|
||||
labels,
|
||||
mode,
|
||||
profile,
|
||||
});
|
||||
if (snapshot.format === "ai") {
|
||||
|
||||
@@ -127,7 +127,7 @@ function registerBrowserToolAfterEachReset() {
|
||||
}
|
||||
|
||||
async function runSnapshotToolCall(params: {
|
||||
snapshotFormat?: "ai" | "aria";
|
||||
snapshotFormat: "ai" | "aria";
|
||||
refs?: "aria" | "dom";
|
||||
maxChars?: number;
|
||||
profile?: string;
|
||||
@@ -243,23 +243,6 @@ describe("browser tool snapshot maxChars", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("lets the server choose snapshot format when the user does not request one", async () => {
|
||||
const tool = createBrowserTool();
|
||||
await tool.execute?.("call-1", { action: "snapshot", profile: "chrome" });
|
||||
|
||||
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
profile: "chrome",
|
||||
}),
|
||||
);
|
||||
const opts = browserClientMocks.browserSnapshot.mock.calls.at(-1)?.[1] as
|
||||
| { format?: string; maxChars?: number }
|
||||
| undefined;
|
||||
expect(opts?.format).toBeUndefined();
|
||||
expect(Object.hasOwn(opts ?? {}, "maxChars")).toBe(false);
|
||||
});
|
||||
|
||||
it("routes to node proxy when target=node", async () => {
|
||||
mockSingleBrowserProxyNode();
|
||||
const tool = createBrowserTool();
|
||||
@@ -267,44 +250,15 @@ describe("browser tool snapshot maxChars", () => {
|
||||
|
||||
expect(gatewayMocks.callGatewayTool).toHaveBeenCalledWith(
|
||||
"node.invoke",
|
||||
{ timeoutMs: 25000 },
|
||||
{ timeoutMs: 20000 },
|
||||
expect.objectContaining({
|
||||
nodeId: "node-1",
|
||||
command: "browser.proxy",
|
||||
params: expect.objectContaining({
|
||||
timeoutMs: 20000,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(browserClientMocks.browserStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("gives node.invoke extra slack beyond the default proxy timeout", async () => {
|
||||
mockSingleBrowserProxyNode();
|
||||
gatewayMocks.callGatewayTool.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
payload: {
|
||||
result: { ok: true, running: true },
|
||||
},
|
||||
});
|
||||
const tool = createBrowserTool();
|
||||
await tool.execute?.("call-1", {
|
||||
action: "dialog",
|
||||
target: "node",
|
||||
accept: true,
|
||||
});
|
||||
|
||||
expect(gatewayMocks.callGatewayTool).toHaveBeenCalledWith(
|
||||
"node.invoke",
|
||||
{ timeoutMs: 25000 },
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
timeoutMs: 20000,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps sandbox bridge url when node proxy is available", async () => {
|
||||
mockSingleBrowserProxyNode();
|
||||
const tool = createBrowserTool({ sandboxBridgeUrl: "http://127.0.0.1:9999" });
|
||||
|
||||
@@ -115,7 +115,6 @@ type BrowserProxyResult = {
|
||||
};
|
||||
|
||||
const DEFAULT_BROWSER_PROXY_TIMEOUT_MS = 20_000;
|
||||
const BROWSER_PROXY_GATEWAY_TIMEOUT_SLACK_MS = 5_000;
|
||||
|
||||
type BrowserNodeTarget = {
|
||||
nodeId: string;
|
||||
@@ -207,11 +206,10 @@ async function callBrowserProxy(params: {
|
||||
timeoutMs?: number;
|
||||
profile?: string;
|
||||
}): Promise<BrowserProxyResult> {
|
||||
const proxyTimeoutMs =
|
||||
const gatewayTimeoutMs =
|
||||
typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs)
|
||||
? Math.max(1, Math.floor(params.timeoutMs))
|
||||
: DEFAULT_BROWSER_PROXY_TIMEOUT_MS;
|
||||
const gatewayTimeoutMs = proxyTimeoutMs + BROWSER_PROXY_GATEWAY_TIMEOUT_SLACK_MS;
|
||||
const payload = await callGatewayTool<{ payloadJSON?: string; payload?: string }>(
|
||||
"node.invoke",
|
||||
{ timeoutMs: gatewayTimeoutMs },
|
||||
@@ -223,7 +221,7 @@ async function callBrowserProxy(params: {
|
||||
path: params.path,
|
||||
query: params.query,
|
||||
body: params.body,
|
||||
timeoutMs: proxyTimeoutMs,
|
||||
timeoutMs: params.timeoutMs,
|
||||
profile: params.profile,
|
||||
},
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
|
||||
@@ -772,25 +772,7 @@ describe("web_search external content wrapping", () => {
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"rejects date_after/date_before in Brave llm-context mode",
|
||||
{
|
||||
query: "test",
|
||||
date_after: "2025-01-01",
|
||||
date_before: "2025-01-31",
|
||||
},
|
||||
"unsupported_date_filter",
|
||||
],
|
||||
[
|
||||
"rejects ui_lang in Brave llm-context mode",
|
||||
{
|
||||
query: "test",
|
||||
ui_lang: "de-DE",
|
||||
},
|
||||
"unsupported_ui_lang",
|
||||
],
|
||||
])("%s", async (_name, input, expectedError) => {
|
||||
it("rejects date_after/date_before in Brave llm-context mode", async () => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "test-key");
|
||||
const mockFetch = installBraveLlmContextFetch({
|
||||
title: "unused",
|
||||
@@ -813,9 +795,45 @@ describe("web_search external content wrapping", () => {
|
||||
},
|
||||
sandboxed: true,
|
||||
});
|
||||
const result = await tool?.execute?.("call-1", input);
|
||||
const result = await tool?.execute?.("call-1", {
|
||||
query: "test",
|
||||
date_after: "2025-01-01",
|
||||
date_before: "2025-01-31",
|
||||
});
|
||||
|
||||
expect(result?.details).toMatchObject({ error: expectedError });
|
||||
expect(result?.details).toMatchObject({ error: "unsupported_date_filter" });
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects ui_lang in Brave llm-context mode", async () => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "test-key");
|
||||
const mockFetch = installBraveLlmContextFetch({
|
||||
title: "unused",
|
||||
url: "https://example.com",
|
||||
snippets: ["unused"],
|
||||
});
|
||||
|
||||
const tool = createWebSearchTool({
|
||||
config: {
|
||||
tools: {
|
||||
web: {
|
||||
search: {
|
||||
provider: "brave",
|
||||
brave: {
|
||||
mode: "llm-context",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
sandboxed: true,
|
||||
});
|
||||
const result = await tool?.execute?.("call-1", {
|
||||
query: "test",
|
||||
ui_lang: "de-DE",
|
||||
});
|
||||
|
||||
expect(result?.details).toMatchObject({ error: "unsupported_ui_lang" });
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -4,11 +4,6 @@ import { describe, expect, it } from "vitest";
|
||||
type BackgroundUtilsModule = {
|
||||
buildRelayWsUrl: (port: number, gatewayToken: string) => Promise<string>;
|
||||
deriveRelayToken: (gatewayToken: string, port: number) => Promise<string>;
|
||||
isLastRemainingTab: (
|
||||
allTabs: Array<{ id?: number | undefined } | null | undefined>,
|
||||
tabIdToClose: number,
|
||||
) => boolean;
|
||||
isMissingTabError: (err: unknown) => boolean;
|
||||
isRetryableReconnectError: (err: unknown) => boolean;
|
||||
reconnectDelayMs: (
|
||||
attempt: number,
|
||||
@@ -31,14 +26,8 @@ async function loadBackgroundUtils(): Promise<BackgroundUtilsModule> {
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
buildRelayWsUrl,
|
||||
deriveRelayToken,
|
||||
isLastRemainingTab,
|
||||
isMissingTabError,
|
||||
isRetryableReconnectError,
|
||||
reconnectDelayMs,
|
||||
} = await loadBackgroundUtils();
|
||||
const { buildRelayWsUrl, deriveRelayToken, isRetryableReconnectError, reconnectDelayMs } =
|
||||
await loadBackgroundUtils();
|
||||
|
||||
describe("chrome extension background utils", () => {
|
||||
it("derives relay token as HMAC-SHA256 of gateway token and port", async () => {
|
||||
@@ -118,16 +107,4 @@ describe("chrome extension background utils", () => {
|
||||
expect(isRetryableReconnectError(new Error("WebSocket connect timeout"))).toBe(true);
|
||||
expect(isRetryableReconnectError(new Error("Relay server not reachable"))).toBe(true);
|
||||
});
|
||||
|
||||
it("recognizes missing-tab debugger errors", () => {
|
||||
expect(isMissingTabError(new Error("No tab with given id"))).toBe(true);
|
||||
expect(isMissingTabError(new Error("tab not found"))).toBe(true);
|
||||
expect(isMissingTabError(new Error("Cannot access a chrome:// URL"))).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks closing the final remaining tab only", () => {
|
||||
expect(isLastRemainingTab([{ id: 7 }], 7)).toBe(true);
|
||||
expect(isLastRemainingTab([{ id: 7 }, { id: 8 }], 7)).toBe(false);
|
||||
expect(isLastRemainingTab([{ id: 7 }, { id: 8 }], 8)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,21 +101,6 @@ describe("browser client", () => {
|
||||
expect(parsed.searchParams.get("refs")).toBe("aria");
|
||||
});
|
||||
|
||||
it("omits format when the caller wants server-side snapshot capability defaults", async () => {
|
||||
const calls: string[] = [];
|
||||
stubSnapshotFetch(calls);
|
||||
|
||||
await browserSnapshot("http://127.0.0.1:18791", {
|
||||
profile: "chrome",
|
||||
});
|
||||
|
||||
const snapshotCall = calls.find((url) => url.includes("/snapshot?"));
|
||||
expect(snapshotCall).toBeTruthy();
|
||||
const parsed = new URL(snapshotCall as string);
|
||||
expect(parsed.searchParams.get("format")).toBeNull();
|
||||
expect(parsed.searchParams.get("profile")).toBe("chrome");
|
||||
});
|
||||
|
||||
it("uses the expected endpoints + methods for common calls", async () => {
|
||||
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
||||
|
||||
|
||||
@@ -276,7 +276,7 @@ export async function browserTabAction(
|
||||
export async function browserSnapshot(
|
||||
baseUrl: string | undefined,
|
||||
opts: {
|
||||
format?: "aria" | "ai";
|
||||
format: "aria" | "ai";
|
||||
targetId?: string;
|
||||
limit?: number;
|
||||
maxChars?: number;
|
||||
@@ -292,9 +292,7 @@ export async function browserSnapshot(
|
||||
},
|
||||
): Promise<SnapshotResult> {
|
||||
const q = new URLSearchParams();
|
||||
if (opts.format) {
|
||||
q.set("format", opts.format);
|
||||
}
|
||||
q.set("format", opts.format);
|
||||
if (opts.targetId) {
|
||||
q.set("targetId", opts.targetId);
|
||||
}
|
||||
|
||||
@@ -115,67 +115,4 @@ describe("pw-session getPageForTargetId", () => {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves extension-relay pages from /json/list without probing page CDP sessions first", async () => {
|
||||
const pageOn = vi.fn();
|
||||
const contextOn = vi.fn();
|
||||
const browserOn = vi.fn();
|
||||
const browserClose = vi.fn(async () => {});
|
||||
const newCDPSession = vi.fn(async () => {
|
||||
throw new Error("Target.attachToBrowserTarget: Not allowed");
|
||||
});
|
||||
|
||||
const context = {
|
||||
pages: () => [],
|
||||
on: contextOn,
|
||||
newCDPSession,
|
||||
} as unknown as import("playwright-core").BrowserContext;
|
||||
|
||||
const pageA = {
|
||||
on: pageOn,
|
||||
context: () => context,
|
||||
url: () => "https://alpha.example",
|
||||
} as unknown as import("playwright-core").Page;
|
||||
const pageB = {
|
||||
on: pageOn,
|
||||
context: () => context,
|
||||
url: () => "https://beta.example",
|
||||
} as unknown as import("playwright-core").Page;
|
||||
|
||||
(context as unknown as { pages: () => unknown[] }).pages = () => [pageA, pageB];
|
||||
|
||||
const browser = {
|
||||
contexts: () => [context],
|
||||
on: browserOn,
|
||||
close: browserClose,
|
||||
} as unknown as import("playwright-core").Browser;
|
||||
|
||||
connectOverCdpSpy.mockResolvedValue(browser);
|
||||
getChromeWebSocketUrlSpy.mockResolvedValue(null);
|
||||
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
fetchSpy
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ Browser: "OpenClaw/extension-relay" }),
|
||||
} as Response)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{ id: "TARGET_A", url: "https://alpha.example" },
|
||||
{ id: "TARGET_B", url: "https://beta.example" },
|
||||
],
|
||||
} as Response);
|
||||
|
||||
try {
|
||||
const resolved = await getPageForTargetId({
|
||||
cdpUrl: "http://127.0.0.1:19993",
|
||||
targetId: "TARGET_B",
|
||||
});
|
||||
expect(resolved).toBe(pageB);
|
||||
expect(newCDPSession).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const cdpHelperMocks = vi.hoisted(() => ({
|
||||
fetchJson: vi.fn(),
|
||||
withCdpSocket: vi.fn(),
|
||||
}));
|
||||
|
||||
const chromeMocks = vi.hoisted(() => ({
|
||||
getChromeWebSocketUrl: vi.fn(async () => "ws://127.0.0.1:18792/cdp"),
|
||||
}));
|
||||
|
||||
vi.mock("./cdp.helpers.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./cdp.helpers.js")>("./cdp.helpers.js");
|
||||
return {
|
||||
...actual,
|
||||
fetchJson: cdpHelperMocks.fetchJson,
|
||||
withCdpSocket: cdpHelperMocks.withCdpSocket,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./chrome.js", () => chromeMocks);
|
||||
|
||||
import { isExtensionRelayCdpEndpoint, withPageScopedCdpClient } from "./pw-session.page-cdp.js";
|
||||
|
||||
describe("pw-session page-scoped CDP client", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("uses raw relay /cdp commands for extension endpoints when targetId is known", async () => {
|
||||
cdpHelperMocks.fetchJson.mockResolvedValue({ Browser: "OpenClaw/extension-relay" });
|
||||
const send = vi.fn(async () => ({ ok: true }));
|
||||
cdpHelperMocks.withCdpSocket.mockImplementation(async (_wsUrl, fn) => await fn(send));
|
||||
const newCDPSession = vi.fn();
|
||||
const page = {
|
||||
context: () => ({
|
||||
newCDPSession,
|
||||
}),
|
||||
};
|
||||
|
||||
await withPageScopedCdpClient({
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
page: page as never,
|
||||
targetId: "tab-1",
|
||||
fn: async (pageSend) => {
|
||||
await pageSend("Page.bringToFront", { foo: "bar" });
|
||||
},
|
||||
});
|
||||
|
||||
expect(send).toHaveBeenCalledWith("Page.bringToFront", {
|
||||
foo: "bar",
|
||||
targetId: "tab-1",
|
||||
});
|
||||
expect(newCDPSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to Playwright page sessions for non-relay endpoints", async () => {
|
||||
cdpHelperMocks.fetchJson.mockResolvedValue({ Browser: "Chrome/145.0" });
|
||||
const sessionSend = vi.fn(async () => ({ ok: true }));
|
||||
const sessionDetach = vi.fn(async () => {});
|
||||
const newCDPSession = vi.fn(async () => ({
|
||||
send: sessionSend,
|
||||
detach: sessionDetach,
|
||||
}));
|
||||
const page = {
|
||||
context: () => ({
|
||||
newCDPSession,
|
||||
}),
|
||||
};
|
||||
|
||||
await withPageScopedCdpClient({
|
||||
cdpUrl: "http://127.0.0.1:9222",
|
||||
page: page as never,
|
||||
targetId: "tab-1",
|
||||
fn: async (pageSend) => {
|
||||
await pageSend("Emulation.setLocaleOverride", { locale: "en-US" });
|
||||
},
|
||||
});
|
||||
|
||||
expect(newCDPSession).toHaveBeenCalledWith(page);
|
||||
expect(sessionSend).toHaveBeenCalledWith("Emulation.setLocaleOverride", { locale: "en-US" });
|
||||
expect(sessionDetach).toHaveBeenCalledTimes(1);
|
||||
expect(cdpHelperMocks.withCdpSocket).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("caches extension-relay endpoint detection by cdpUrl", async () => {
|
||||
cdpHelperMocks.fetchJson.mockResolvedValue({ Browser: "OpenClaw/extension-relay" });
|
||||
|
||||
await expect(isExtensionRelayCdpEndpoint("http://127.0.0.1:19992")).resolves.toBe(true);
|
||||
await expect(isExtensionRelayCdpEndpoint("http://127.0.0.1:19992/")).resolves.toBe(true);
|
||||
|
||||
expect(cdpHelperMocks.fetchJson).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,81 +0,0 @@
|
||||
import type { CDPSession, Page } from "playwright-core";
|
||||
import {
|
||||
appendCdpPath,
|
||||
fetchJson,
|
||||
normalizeCdpHttpBaseForJsonEndpoints,
|
||||
withCdpSocket,
|
||||
} from "./cdp.helpers.js";
|
||||
import { getChromeWebSocketUrl } from "./chrome.js";
|
||||
|
||||
const OPENCLAW_EXTENSION_RELAY_BROWSER = "OpenClaw/extension-relay";
|
||||
|
||||
type PageCdpSend = (method: string, params?: Record<string, unknown>) => Promise<unknown>;
|
||||
|
||||
const extensionRelayByCdpUrl = new Map<string, boolean>();
|
||||
|
||||
function normalizeCdpUrl(raw: string) {
|
||||
return raw.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
export async function isExtensionRelayCdpEndpoint(cdpUrl: string): Promise<boolean> {
|
||||
const normalized = normalizeCdpUrl(cdpUrl);
|
||||
const cached = extensionRelayByCdpUrl.get(normalized);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(normalized);
|
||||
const version = await fetchJson<{ Browser?: string }>(
|
||||
appendCdpPath(cdpHttpBase, "/json/version"),
|
||||
2000,
|
||||
);
|
||||
const isRelay = String(version?.Browser ?? "").trim() === OPENCLAW_EXTENSION_RELAY_BROWSER;
|
||||
extensionRelayByCdpUrl.set(normalized, isRelay);
|
||||
return isRelay;
|
||||
} catch {
|
||||
extensionRelayByCdpUrl.set(normalized, false);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function withPlaywrightPageCdpSession<T>(
|
||||
page: Page,
|
||||
fn: (session: CDPSession) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const session = await page.context().newCDPSession(page);
|
||||
try {
|
||||
return await fn(session);
|
||||
} finally {
|
||||
await session.detach().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export async function withPageScopedCdpClient<T>(opts: {
|
||||
cdpUrl: string;
|
||||
page: Page;
|
||||
targetId?: string;
|
||||
fn: (send: PageCdpSend) => Promise<T>;
|
||||
}): Promise<T> {
|
||||
const targetId = opts.targetId?.trim();
|
||||
if (targetId && (await isExtensionRelayCdpEndpoint(opts.cdpUrl))) {
|
||||
const wsUrl = await getChromeWebSocketUrl(opts.cdpUrl, 2000);
|
||||
if (!wsUrl) {
|
||||
throw new Error("CDP websocket unavailable");
|
||||
}
|
||||
return await withCdpSocket(wsUrl, async (send) => {
|
||||
return await opts.fn((method, params) => send(method, { ...params, targetId }));
|
||||
});
|
||||
}
|
||||
|
||||
return await withPlaywrightPageCdpSession(opts.page, async (session) => {
|
||||
return await opts.fn((method, params) =>
|
||||
(
|
||||
session.send as unknown as (
|
||||
method: string,
|
||||
params?: Record<string, unknown>,
|
||||
) => Promise<unknown>
|
||||
)(method, params),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
assertBrowserNavigationResultAllowed,
|
||||
withBrowserNavigationPolicy,
|
||||
} from "./navigation-guard.js";
|
||||
import { isExtensionRelayCdpEndpoint, withPageScopedCdpClient } from "./pw-session.page-cdp.js";
|
||||
|
||||
export type BrowserConsoleMessage = {
|
||||
type: string;
|
||||
@@ -399,70 +398,14 @@ async function pageTargetId(page: Page): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
function matchPageByTargetList(
|
||||
pages: Page[],
|
||||
targets: Array<{ id: string; url: string; title?: string }>,
|
||||
targetId: string,
|
||||
): Page | null {
|
||||
const target = targets.find((entry) => entry.id === targetId);
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const urlMatch = pages.filter((page) => page.url() === target.url);
|
||||
if (urlMatch.length === 1) {
|
||||
return urlMatch[0] ?? null;
|
||||
}
|
||||
if (urlMatch.length > 1) {
|
||||
const sameUrlTargets = targets.filter((entry) => entry.url === target.url);
|
||||
if (sameUrlTargets.length === urlMatch.length) {
|
||||
const idx = sameUrlTargets.findIndex((entry) => entry.id === targetId);
|
||||
if (idx >= 0 && idx < urlMatch.length) {
|
||||
return urlMatch[idx] ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function findPageByTargetIdViaTargetList(
|
||||
pages: Page[],
|
||||
targetId: string,
|
||||
cdpUrl: string,
|
||||
): Promise<Page | null> {
|
||||
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(cdpUrl);
|
||||
const targets = await fetchJson<
|
||||
Array<{
|
||||
id: string;
|
||||
url: string;
|
||||
title?: string;
|
||||
}>
|
||||
>(appendCdpPath(cdpHttpBase, "/json/list"), 2000);
|
||||
return matchPageByTargetList(pages, targets, targetId);
|
||||
}
|
||||
|
||||
async function findPageByTargetId(
|
||||
browser: Browser,
|
||||
targetId: string,
|
||||
cdpUrl?: string,
|
||||
): Promise<Page | null> {
|
||||
const pages = await getAllPages(browser);
|
||||
const isExtensionRelay = cdpUrl
|
||||
? await isExtensionRelayCdpEndpoint(cdpUrl).catch(() => false)
|
||||
: false;
|
||||
if (cdpUrl && isExtensionRelay) {
|
||||
try {
|
||||
const matched = await findPageByTargetIdViaTargetList(pages, targetId, cdpUrl);
|
||||
if (matched) {
|
||||
return matched;
|
||||
}
|
||||
} catch {
|
||||
// Ignore fetch errors and fall through to best-effort single-page fallback.
|
||||
}
|
||||
return pages.length === 1 ? (pages[0] ?? null) : null;
|
||||
}
|
||||
|
||||
let resolvedViaCdp = false;
|
||||
// First, try the standard CDP session approach
|
||||
for (const page of pages) {
|
||||
let tid: string | null = null;
|
||||
try {
|
||||
@@ -475,16 +418,46 @@ async function findPageByTargetId(
|
||||
return page;
|
||||
}
|
||||
}
|
||||
// Extension relays can block CDP attachment APIs entirely. If that happens and
|
||||
// Playwright only exposes one page, return it as the best available mapping.
|
||||
if (!resolvedViaCdp && pages.length === 1) {
|
||||
return pages[0];
|
||||
}
|
||||
// If CDP sessions fail (e.g., extension relay blocks Target.attachToBrowserTarget),
|
||||
// fall back to URL-based matching using the /json/list endpoint
|
||||
if (cdpUrl) {
|
||||
try {
|
||||
return await findPageByTargetIdViaTargetList(pages, targetId, cdpUrl);
|
||||
const cdpHttpBase = normalizeCdpHttpBaseForJsonEndpoints(cdpUrl);
|
||||
const targets = await fetchJson<
|
||||
Array<{
|
||||
id: string;
|
||||
url: string;
|
||||
title?: string;
|
||||
}>
|
||||
>(appendCdpPath(cdpHttpBase, "/json/list"), 2000);
|
||||
const target = targets.find((t) => t.id === targetId);
|
||||
if (target) {
|
||||
// Try to find a page with matching URL
|
||||
const urlMatch = pages.filter((p) => p.url() === target.url);
|
||||
if (urlMatch.length === 1) {
|
||||
return urlMatch[0];
|
||||
}
|
||||
// If multiple URL matches, use index-based matching as fallback
|
||||
// This works when Playwright and the relay enumerate tabs in the same order
|
||||
if (urlMatch.length > 1) {
|
||||
const sameUrlTargets = targets.filter((t) => t.url === target.url);
|
||||
if (sameUrlTargets.length === urlMatch.length) {
|
||||
const idx = sameUrlTargets.findIndex((t) => t.id === targetId);
|
||||
if (idx >= 0 && idx < urlMatch.length) {
|
||||
return urlMatch[idx];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore fetch errors and fall through to return null.
|
||||
// Ignore fetch errors and fall through to return null
|
||||
}
|
||||
}
|
||||
if (!resolvedViaCdp && pages.length === 1) {
|
||||
return pages[0] ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -833,18 +806,14 @@ export async function focusPageByTargetIdViaPlaywright(opts: {
|
||||
try {
|
||||
await page.bringToFront();
|
||||
} catch (err) {
|
||||
const session = await page.context().newCDPSession(page);
|
||||
try {
|
||||
await withPageScopedCdpClient({
|
||||
cdpUrl: opts.cdpUrl,
|
||||
page,
|
||||
targetId: opts.targetId,
|
||||
fn: async (send) => {
|
||||
await send("Page.bringToFront");
|
||||
},
|
||||
});
|
||||
await session.send("Page.bringToFront");
|
||||
return;
|
||||
} catch {
|
||||
throw err;
|
||||
} finally {
|
||||
await session.detach().catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
storeRoleRefsForTarget,
|
||||
type WithSnapshotForAI,
|
||||
} from "./pw-session.js";
|
||||
import { withPageScopedCdpClient } from "./pw-session.page-cdp.js";
|
||||
|
||||
export async function snapshotAriaViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
@@ -32,21 +31,17 @@ export async function snapshotAriaViaPlaywright(opts: {
|
||||
targetId: opts.targetId,
|
||||
});
|
||||
ensurePageState(page);
|
||||
const res = (await withPageScopedCdpClient({
|
||||
cdpUrl: opts.cdpUrl,
|
||||
page,
|
||||
targetId: opts.targetId,
|
||||
fn: async (send) => {
|
||||
await send("Accessibility.enable").catch(() => {});
|
||||
return (await send("Accessibility.getFullAXTree")) as {
|
||||
nodes?: RawAXNode[];
|
||||
};
|
||||
},
|
||||
})) as {
|
||||
nodes?: RawAXNode[];
|
||||
};
|
||||
const nodes = Array.isArray(res?.nodes) ? res.nodes : [];
|
||||
return { nodes: formatAriaSnapshot(nodes, limit) };
|
||||
const session = await page.context().newCDPSession(page);
|
||||
try {
|
||||
await session.send("Accessibility.enable").catch(() => {});
|
||||
const res = (await session.send("Accessibility.getFullAXTree")) as {
|
||||
nodes?: RawAXNode[];
|
||||
};
|
||||
const nodes = Array.isArray(res?.nodes) ? res.nodes : [];
|
||||
return { nodes: formatAriaSnapshot(nodes, limit) };
|
||||
} finally {
|
||||
await session.detach().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export async function snapshotAiViaPlaywright(opts: {
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { CDPSession, Page } from "playwright-core";
|
||||
import { devices as playwrightDevices } from "playwright-core";
|
||||
import { ensurePageState, getPageForTargetId } from "./pw-session.js";
|
||||
import { withPageScopedCdpClient } from "./pw-session.page-cdp.js";
|
||||
|
||||
async function withCdpSession<T>(page: Page, fn: (session: CDPSession) => Promise<T>): Promise<T> {
|
||||
const session = await page.context().newCDPSession(page);
|
||||
try {
|
||||
return await fn(session);
|
||||
} finally {
|
||||
await session.detach().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export async function setOfflineViaPlaywright(opts: {
|
||||
cdpUrl: string;
|
||||
@@ -103,20 +112,15 @@ export async function setLocaleViaPlaywright(opts: {
|
||||
if (!locale) {
|
||||
throw new Error("locale is required");
|
||||
}
|
||||
await withPageScopedCdpClient({
|
||||
cdpUrl: opts.cdpUrl,
|
||||
page,
|
||||
targetId: opts.targetId,
|
||||
fn: async (send) => {
|
||||
try {
|
||||
await send("Emulation.setLocaleOverride", { locale });
|
||||
} catch (err) {
|
||||
if (String(err).includes("Another locale override is already in effect")) {
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
await withCdpSession(page, async (session) => {
|
||||
try {
|
||||
await session.send("Emulation.setLocaleOverride", { locale });
|
||||
} catch (err) {
|
||||
if (String(err).includes("Another locale override is already in effect")) {
|
||||
return;
|
||||
}
|
||||
},
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -131,24 +135,19 @@ export async function setTimezoneViaPlaywright(opts: {
|
||||
if (!timezoneId) {
|
||||
throw new Error("timezoneId is required");
|
||||
}
|
||||
await withPageScopedCdpClient({
|
||||
cdpUrl: opts.cdpUrl,
|
||||
page,
|
||||
targetId: opts.targetId,
|
||||
fn: async (send) => {
|
||||
try {
|
||||
await send("Emulation.setTimezoneOverride", { timezoneId });
|
||||
} catch (err) {
|
||||
const msg = String(err);
|
||||
if (msg.includes("Timezone override is already in effect")) {
|
||||
return;
|
||||
}
|
||||
if (msg.includes("Invalid timezone")) {
|
||||
throw new Error(`Invalid timezone ID: ${timezoneId}`, { cause: err });
|
||||
}
|
||||
throw err;
|
||||
await withCdpSession(page, async (session) => {
|
||||
try {
|
||||
await session.send("Emulation.setTimezoneOverride", { timezoneId });
|
||||
} catch (err) {
|
||||
const msg = String(err);
|
||||
if (msg.includes("Timezone override is already in effect")) {
|
||||
return;
|
||||
}
|
||||
},
|
||||
if (msg.includes("Invalid timezone")) {
|
||||
throw new Error(`Invalid timezone ID: ${timezoneId}`, { cause: err });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -184,32 +183,27 @@ export async function setDeviceViaPlaywright(opts: {
|
||||
});
|
||||
}
|
||||
|
||||
await withPageScopedCdpClient({
|
||||
cdpUrl: opts.cdpUrl,
|
||||
page,
|
||||
targetId: opts.targetId,
|
||||
fn: async (send) => {
|
||||
if (descriptor.userAgent || descriptor.locale) {
|
||||
await send("Emulation.setUserAgentOverride", {
|
||||
userAgent: descriptor.userAgent ?? "",
|
||||
acceptLanguage: descriptor.locale ?? undefined,
|
||||
});
|
||||
}
|
||||
if (descriptor.viewport) {
|
||||
await send("Emulation.setDeviceMetricsOverride", {
|
||||
mobile: Boolean(descriptor.isMobile),
|
||||
width: descriptor.viewport.width,
|
||||
height: descriptor.viewport.height,
|
||||
deviceScaleFactor: descriptor.deviceScaleFactor ?? 1,
|
||||
screenWidth: descriptor.viewport.width,
|
||||
screenHeight: descriptor.viewport.height,
|
||||
});
|
||||
}
|
||||
if (descriptor.hasTouch) {
|
||||
await send("Emulation.setTouchEmulationEnabled", {
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
await withCdpSession(page, async (session) => {
|
||||
if (descriptor.userAgent || descriptor.locale) {
|
||||
await session.send("Emulation.setUserAgentOverride", {
|
||||
userAgent: descriptor.userAgent ?? "",
|
||||
acceptLanguage: descriptor.locale ?? undefined,
|
||||
});
|
||||
}
|
||||
if (descriptor.viewport) {
|
||||
await session.send("Emulation.setDeviceMetricsOverride", {
|
||||
mobile: Boolean(descriptor.isMobile),
|
||||
width: descriptor.viewport.width,
|
||||
height: descriptor.viewport.height,
|
||||
deviceScaleFactor: descriptor.deviceScaleFactor ?? 1,
|
||||
screenWidth: descriptor.viewport.width,
|
||||
screenHeight: descriptor.viewport.height,
|
||||
});
|
||||
}
|
||||
if (descriptor.hasTouch) {
|
||||
await session.send("Emulation.setTouchEmulationEnabled", {
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,27 +5,17 @@ const { resolveProfileMock, ensureChromeExtensionRelayServerMock } = vi.hoisted(
|
||||
ensureChromeExtensionRelayServerMock: vi.fn(),
|
||||
}));
|
||||
|
||||
const { stopOpenClawChromeMock, stopChromeExtensionRelayServerMock } = vi.hoisted(() => ({
|
||||
stopOpenClawChromeMock: vi.fn(async () => {}),
|
||||
stopChromeExtensionRelayServerMock: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
const { createBrowserRouteContextMock, listKnownProfileNamesMock } = vi.hoisted(() => ({
|
||||
createBrowserRouteContextMock: vi.fn(),
|
||||
listKnownProfileNamesMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./chrome.js", () => ({
|
||||
stopOpenClawChrome: stopOpenClawChromeMock,
|
||||
}));
|
||||
|
||||
vi.mock("./config.js", () => ({
|
||||
resolveProfile: resolveProfileMock,
|
||||
}));
|
||||
|
||||
vi.mock("./extension-relay.js", () => ({
|
||||
ensureChromeExtensionRelayServer: ensureChromeExtensionRelayServerMock,
|
||||
stopChromeExtensionRelayServer: stopChromeExtensionRelayServerMock,
|
||||
}));
|
||||
|
||||
vi.mock("./server-context.js", () => ({
|
||||
@@ -86,8 +76,6 @@ describe("stopKnownBrowserProfiles", () => {
|
||||
beforeEach(() => {
|
||||
createBrowserRouteContextMock.mockClear();
|
||||
listKnownProfileNamesMock.mockClear();
|
||||
stopOpenClawChromeMock.mockClear();
|
||||
stopChromeExtensionRelayServerMock.mockClear();
|
||||
});
|
||||
|
||||
it("stops all known profiles and ignores per-profile failures", async () => {
|
||||
@@ -116,53 +104,6 @@ describe("stopKnownBrowserProfiles", () => {
|
||||
expect(onWarn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops tracked runtime browsers even when the profile no longer resolves", async () => {
|
||||
listKnownProfileNamesMock.mockReturnValue(["deleted-local", "deleted-extension"]);
|
||||
createBrowserRouteContextMock.mockReturnValue({
|
||||
forProfile: vi.fn(() => {
|
||||
throw new Error("profile not found");
|
||||
}),
|
||||
});
|
||||
const localRuntime = {
|
||||
profile: {
|
||||
name: "deleted-local",
|
||||
driver: "openclaw",
|
||||
},
|
||||
running: {
|
||||
pid: 42,
|
||||
cdpPort: 18888,
|
||||
},
|
||||
};
|
||||
const launchedBrowser = localRuntime.running;
|
||||
const extensionRuntime = {
|
||||
profile: {
|
||||
name: "deleted-extension",
|
||||
driver: "extension",
|
||||
cdpUrl: "http://127.0.0.1:19999",
|
||||
},
|
||||
running: null,
|
||||
};
|
||||
const profiles = new Map<string, unknown>([
|
||||
["deleted-local", localRuntime],
|
||||
["deleted-extension", extensionRuntime],
|
||||
]);
|
||||
const state = {
|
||||
resolved: { profiles: {} },
|
||||
profiles,
|
||||
};
|
||||
|
||||
await stopKnownBrowserProfiles({
|
||||
getState: () => state as never,
|
||||
onWarn: vi.fn(),
|
||||
});
|
||||
|
||||
expect(stopOpenClawChromeMock).toHaveBeenCalledWith(launchedBrowser);
|
||||
expect(localRuntime.running).toBeNull();
|
||||
expect(stopChromeExtensionRelayServerMock).toHaveBeenCalledWith({
|
||||
cdpUrl: "http://127.0.0.1:19999",
|
||||
});
|
||||
});
|
||||
|
||||
it("warns when profile enumeration fails", async () => {
|
||||
listKnownProfileNamesMock.mockImplementation(() => {
|
||||
throw new Error("oops");
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { stopOpenClawChrome } from "./chrome.js";
|
||||
import type { ResolvedBrowserConfig } from "./config.js";
|
||||
import { resolveProfile } from "./config.js";
|
||||
import {
|
||||
ensureChromeExtensionRelayServer,
|
||||
stopChromeExtensionRelayServer,
|
||||
} from "./extension-relay.js";
|
||||
import { ensureChromeExtensionRelayServer } from "./extension-relay.js";
|
||||
import {
|
||||
type BrowserServerState,
|
||||
createBrowserRouteContext,
|
||||
@@ -44,18 +40,6 @@ export async function stopKnownBrowserProfiles(params: {
|
||||
try {
|
||||
for (const name of listKnownProfileNames(current)) {
|
||||
try {
|
||||
const runtime = current.profiles.get(name);
|
||||
if (runtime?.running) {
|
||||
await stopOpenClawChrome(runtime.running);
|
||||
runtime.running = null;
|
||||
continue;
|
||||
}
|
||||
if (runtime?.profile.driver === "extension") {
|
||||
await stopChromeExtensionRelayServer({ cdpUrl: runtime.profile.cdpUrl }).catch(
|
||||
() => false,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
await ctx.forProfile(name).stopRunningBrowser();
|
||||
} catch {
|
||||
// ignore
|
||||
|
||||
@@ -1,25 +1,10 @@
|
||||
import { z, type ZodTypeAny } from "zod";
|
||||
import type { ZodTypeAny } from "zod";
|
||||
import type { ChannelConfigSchema } from "./types.plugin.js";
|
||||
|
||||
type ZodSchemaWithToJsonSchema = ZodTypeAny & {
|
||||
toJSONSchema?: (params?: Record<string, unknown>) => unknown;
|
||||
};
|
||||
|
||||
type ExtendableZodObject = ZodTypeAny & {
|
||||
extend: (shape: Record<string, ZodTypeAny>) => ZodTypeAny;
|
||||
};
|
||||
|
||||
export const AllowFromEntrySchema = z.union([z.string(), z.number()]);
|
||||
|
||||
export function buildCatchallMultiAccountChannelSchema<T extends ExtendableZodObject>(
|
||||
accountSchema: T,
|
||||
): T {
|
||||
return accountSchema.extend({
|
||||
accounts: z.object({}).catchall(accountSchema).optional(),
|
||||
defaultAccount: z.string().optional(),
|
||||
}) as T;
|
||||
}
|
||||
|
||||
export function buildChannelConfigSchema(schema: ZodTypeAny): ChannelConfigSchema {
|
||||
const schemaWithJson = schema as ZodSchemaWithToJsonSchema;
|
||||
if (typeof schemaWithJson.toJSONSchema === "function") {
|
||||
|
||||
@@ -36,17 +36,16 @@ const renderGatewayPortHealthDiagnostics = vi.fn(() => ["diag: unhealthy port"])
|
||||
const renderRestartDiagnostics = vi.fn(() => ["diag: unhealthy runtime"]);
|
||||
const resolveGatewayPort = vi.fn(() => 18789);
|
||||
const findGatewayPidsOnPortSync = vi.fn<(port: number) => number[]>(() => []);
|
||||
const probeGateway =
|
||||
vi.fn<
|
||||
(opts: {
|
||||
url: string;
|
||||
auth?: { token?: string; password?: string };
|
||||
timeoutMs: number;
|
||||
}) => Promise<{
|
||||
ok: boolean;
|
||||
configSnapshot: unknown;
|
||||
}>
|
||||
>();
|
||||
const probeGateway = vi.fn<
|
||||
(opts: {
|
||||
url: string;
|
||||
auth?: { token?: string; password?: string };
|
||||
timeoutMs: number;
|
||||
}) => Promise<{
|
||||
ok: boolean;
|
||||
configSnapshot: unknown;
|
||||
}>
|
||||
>();
|
||||
const isRestartEnabled = vi.fn<(config?: { commands?: unknown }) => boolean>(() => true);
|
||||
const loadConfig = vi.fn(() => ({}));
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import { readBestEffortConfig, resolveGatewayPort } from "../../config/config.js
|
||||
import { parseCmdScriptCommandLine } from "../../daemon/cmd-argv.js";
|
||||
import { resolveGatewayService } from "../../daemon/service.js";
|
||||
import { probeGateway } from "../../gateway/probe.js";
|
||||
import { isGatewayArgv, parseProcCmdline } from "../../infra/gateway-process-argv.js";
|
||||
import { findGatewayPidsOnPortSync } from "../../infra/restart.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { theme } from "../../terminal/theme.js";
|
||||
@@ -43,6 +42,17 @@ async function resolveGatewayLifecyclePort(service = resolveGatewayService()) {
|
||||
return portFromArgs ?? resolveGatewayPort(await readBestEffortConfig(), mergedEnv);
|
||||
}
|
||||
|
||||
function normalizeProcArg(arg: string): string {
|
||||
return arg.replaceAll("\\", "/").toLowerCase();
|
||||
}
|
||||
|
||||
function parseProcCmdline(raw: string): string[] {
|
||||
return raw
|
||||
.split("\0")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function extractWindowsCommandLine(raw: string): string | null {
|
||||
const lines = raw
|
||||
.split(/\r?\n/)
|
||||
@@ -58,6 +68,31 @@ function extractWindowsCommandLine(raw: string): string | null {
|
||||
return lines.find((line) => line.toLowerCase() !== "commandline") ?? null;
|
||||
}
|
||||
|
||||
function stripExecutableExtension(value: string): string {
|
||||
return value.replace(/\.(bat|cmd|exe)$/i, "");
|
||||
}
|
||||
|
||||
function isGatewayArgv(args: string[]): boolean {
|
||||
const normalized = args.map(normalizeProcArg);
|
||||
if (!normalized.includes("gateway")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const entryCandidates = [
|
||||
"dist/index.js",
|
||||
"dist/entry.js",
|
||||
"openclaw.mjs",
|
||||
"scripts/run-node.mjs",
|
||||
"src/index.ts",
|
||||
];
|
||||
if (normalized.some((arg) => entryCandidates.some((entry) => arg.endsWith(entry)))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const exe = stripExecutableExtension(normalized[0] ?? "");
|
||||
return exe.endsWith("/openclaw") || exe === "openclaw" || exe.endsWith("/openclaw-gateway");
|
||||
}
|
||||
|
||||
function readGatewayProcessArgsSync(pid: number): string[] | null {
|
||||
if (process.platform === "linux") {
|
||||
try {
|
||||
@@ -100,7 +135,7 @@ function resolveGatewayListenerPids(port: number): number[] {
|
||||
.filter((pid): pid is number => Number.isFinite(pid) && pid > 0)
|
||||
.filter((pid) => {
|
||||
const args = readGatewayProcessArgsSync(pid);
|
||||
return args != null && isGatewayArgv(args, { allowGatewayBinary: true });
|
||||
return args != null && isGatewayArgv(args);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -112,7 +147,7 @@ function resolveGatewayPortFallback(): Promise<number> {
|
||||
|
||||
function signalGatewayPid(pid: number, signal: "SIGTERM" | "SIGUSR1") {
|
||||
const args = readGatewayProcessArgsSync(pid);
|
||||
if (!args || !isGatewayArgv(args, { allowGatewayBinary: true })) {
|
||||
if (!args || !isGatewayArgv(args)) {
|
||||
throw new Error(`refusing to signal non-gateway process pid ${pid}`);
|
||||
}
|
||||
process.kill(pid, signal);
|
||||
|
||||
@@ -242,22 +242,6 @@ export async function waitForGatewayHealthyListener(params: {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function renderPortUsageDiagnostics(snapshot: GatewayPortHealthSnapshot): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
if (snapshot.portUsage.status === "busy") {
|
||||
lines.push(...formatPortDiagnostics(snapshot.portUsage));
|
||||
} else {
|
||||
lines.push(`Gateway port ${snapshot.portUsage.port} status: ${snapshot.portUsage.status}.`);
|
||||
}
|
||||
|
||||
if (snapshot.portUsage.errors?.length) {
|
||||
lines.push(`Port diagnostics errors: ${snapshot.portUsage.errors.join("; ")}`);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function renderRestartDiagnostics(snapshot: GatewayRestartSnapshot): string[] {
|
||||
const lines: string[] = [];
|
||||
const runtimeSummary = [
|
||||
@@ -273,13 +257,33 @@ export function renderRestartDiagnostics(snapshot: GatewayRestartSnapshot): stri
|
||||
lines.push(`Service runtime: ${runtimeSummary}`);
|
||||
}
|
||||
|
||||
lines.push(...renderPortUsageDiagnostics(snapshot));
|
||||
if (snapshot.portUsage.status === "busy") {
|
||||
lines.push(...formatPortDiagnostics(snapshot.portUsage));
|
||||
} else {
|
||||
lines.push(`Gateway port ${snapshot.portUsage.port} status: ${snapshot.portUsage.status}.`);
|
||||
}
|
||||
|
||||
if (snapshot.portUsage.errors?.length) {
|
||||
lines.push(`Port diagnostics errors: ${snapshot.portUsage.errors.join("; ")}`);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function renderGatewayPortHealthDiagnostics(snapshot: GatewayPortHealthSnapshot): string[] {
|
||||
return renderPortUsageDiagnostics(snapshot);
|
||||
const lines: string[] = [];
|
||||
|
||||
if (snapshot.portUsage.status === "busy") {
|
||||
lines.push(...formatPortDiagnostics(snapshot.portUsage));
|
||||
} else {
|
||||
lines.push(`Gateway port ${snapshot.portUsage.port} status: ${snapshot.portUsage.status}.`);
|
||||
}
|
||||
|
||||
if (snapshot.portUsage.errors?.length) {
|
||||
lines.push(`Port diagnostics errors: ${snapshot.portUsage.errors.join("; ")}`);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
export async function terminateStaleGatewayPids(pids: number[]): Promise<number[]> {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export {
|
||||
clearConfigCache,
|
||||
ConfigRuntimeRefreshError,
|
||||
clearRuntimeConfigSnapshot,
|
||||
createConfigIO,
|
||||
getRuntimeConfigSnapshot,
|
||||
@@ -11,7 +10,6 @@ export {
|
||||
readConfigFileSnapshot,
|
||||
readConfigFileSnapshotForWrite,
|
||||
resolveConfigSnapshotHash,
|
||||
setRuntimeConfigSnapshotRefreshHandler,
|
||||
setRuntimeConfigSnapshot,
|
||||
writeConfigFile,
|
||||
} from "./io.js";
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
clearRuntimeConfigSnapshot,
|
||||
getRuntimeConfigSourceSnapshot,
|
||||
loadConfig,
|
||||
setRuntimeConfigSnapshotRefreshHandler,
|
||||
setRuntimeConfigSnapshot,
|
||||
writeConfigFile,
|
||||
} from "./io.js";
|
||||
@@ -42,7 +41,6 @@ function createRuntimeConfig(): OpenClawConfig {
|
||||
}
|
||||
|
||||
function resetRuntimeConfigState(): void {
|
||||
setRuntimeConfigSnapshotRefreshHandler(null);
|
||||
clearRuntimeConfigSnapshot();
|
||||
clearConfigCache();
|
||||
}
|
||||
@@ -98,117 +96,4 @@ describe("runtime config snapshot writes", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("refreshes the runtime snapshot after writes so follow-up reads see persisted changes", async () => {
|
||||
await withTempHome("openclaw-config-runtime-write-refresh-", async (home) => {
|
||||
const configPath = path.join(home, ".openclaw", "openclaw.json");
|
||||
const sourceConfig: OpenClawConfig = {
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const runtimeConfig: OpenClawConfig = {
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
apiKey: "sk-runtime-resolved", // pragma: allowlist secret
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const nextRuntimeConfig: OpenClawConfig = {
|
||||
...runtimeConfig,
|
||||
gateway: { auth: { mode: "token" as const } },
|
||||
};
|
||||
|
||||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||
await fs.writeFile(configPath, `${JSON.stringify(sourceConfig, null, 2)}\n`, "utf8");
|
||||
|
||||
try {
|
||||
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
|
||||
expect(loadConfig().gateway?.auth).toBeUndefined();
|
||||
|
||||
await writeConfigFile(nextRuntimeConfig);
|
||||
|
||||
expect(loadConfig().gateway?.auth).toEqual({ mode: "token" });
|
||||
expect(loadConfig().models?.providers?.openai?.apiKey).toBeDefined();
|
||||
|
||||
let persisted = JSON.parse(await fs.readFile(configPath, "utf8")) as {
|
||||
gateway?: { auth?: unknown };
|
||||
models?: { providers?: { openai?: { apiKey?: unknown } } };
|
||||
};
|
||||
expect(persisted.gateway?.auth).toEqual({ mode: "token" });
|
||||
// Post-write secret-ref: apiKey must stay as source ref (not plaintext).
|
||||
expect(persisted.models?.providers?.openai?.apiKey).toEqual({
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "OPENAI_API_KEY",
|
||||
});
|
||||
|
||||
// Follow-up write: runtimeConfigSourceSnapshot must be restored so second write
|
||||
// still runs secret-preservation merge-patch and keeps apiKey as ref (not plaintext).
|
||||
await writeConfigFile(loadConfig());
|
||||
persisted = JSON.parse(await fs.readFile(configPath, "utf8")) as {
|
||||
gateway?: { auth?: unknown };
|
||||
models?: { providers?: { openai?: { apiKey?: unknown } } };
|
||||
};
|
||||
expect(persisted.models?.providers?.openai?.apiKey).toEqual({
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "OPENAI_API_KEY",
|
||||
});
|
||||
} finally {
|
||||
clearRuntimeConfigSnapshot();
|
||||
clearConfigCache();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the last-known-good runtime snapshot active while a specialized refresh is pending", async () => {
|
||||
await withTempHome("openclaw-config-runtime-refresh-pending-", async (home) => {
|
||||
const configPath = path.join(home, ".openclaw", "openclaw.json");
|
||||
const sourceConfig = createSourceConfig();
|
||||
const runtimeConfig = createRuntimeConfig();
|
||||
const nextRuntimeConfig: OpenClawConfig = {
|
||||
...runtimeConfig,
|
||||
gateway: { auth: { mode: "token" as const } },
|
||||
};
|
||||
|
||||
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
||||
await fs.writeFile(configPath, `${JSON.stringify(sourceConfig, null, 2)}\n`, "utf8");
|
||||
|
||||
let releaseRefresh!: () => void;
|
||||
const refreshPending = new Promise<boolean>((resolve) => {
|
||||
releaseRefresh = () => resolve(true);
|
||||
});
|
||||
|
||||
try {
|
||||
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
|
||||
setRuntimeConfigSnapshotRefreshHandler({
|
||||
refresh: async ({ sourceConfig: refreshedSource }) => {
|
||||
expect(refreshedSource.gateway?.auth).toEqual({ mode: "token" });
|
||||
expect(loadConfig().gateway?.auth).toBeUndefined();
|
||||
return await refreshPending;
|
||||
},
|
||||
});
|
||||
|
||||
const writePromise = writeConfigFile(nextRuntimeConfig);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(loadConfig().gateway?.auth).toBeUndefined();
|
||||
releaseRefresh();
|
||||
await writePromise;
|
||||
} finally {
|
||||
resetRuntimeConfigState();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -140,22 +140,6 @@ export type ReadConfigFileSnapshotForWriteResult = {
|
||||
writeOptions: ConfigWriteOptions;
|
||||
};
|
||||
|
||||
export type RuntimeConfigSnapshotRefreshParams = {
|
||||
sourceConfig: OpenClawConfig;
|
||||
};
|
||||
|
||||
export type RuntimeConfigSnapshotRefreshHandler = {
|
||||
refresh: (params: RuntimeConfigSnapshotRefreshParams) => boolean | Promise<boolean>;
|
||||
clearOnRefreshFailure?: () => void;
|
||||
};
|
||||
|
||||
export class ConfigRuntimeRefreshError extends Error {
|
||||
constructor(message: string, options?: { cause?: unknown }) {
|
||||
super(message, options);
|
||||
this.name = "ConfigRuntimeRefreshError";
|
||||
}
|
||||
}
|
||||
|
||||
function hashConfigRaw(raw: string | null): string {
|
||||
return crypto
|
||||
.createHash("sha256")
|
||||
@@ -1322,7 +1306,6 @@ let configCache: {
|
||||
} | null = null;
|
||||
let runtimeConfigSnapshot: OpenClawConfig | null = null;
|
||||
let runtimeConfigSourceSnapshot: OpenClawConfig | null = null;
|
||||
let runtimeConfigSnapshotRefreshHandler: RuntimeConfigSnapshotRefreshHandler | null = null;
|
||||
|
||||
function resolveConfigCacheMs(env: NodeJS.ProcessEnv): number {
|
||||
const raw = env.OPENCLAW_CONFIG_CACHE_MS?.trim();
|
||||
@@ -1373,12 +1356,6 @@ export function getRuntimeConfigSourceSnapshot(): OpenClawConfig | null {
|
||||
return runtimeConfigSourceSnapshot;
|
||||
}
|
||||
|
||||
export function setRuntimeConfigSnapshotRefreshHandler(
|
||||
refreshHandler: RuntimeConfigSnapshotRefreshHandler | null,
|
||||
): void {
|
||||
runtimeConfigSnapshotRefreshHandler = refreshHandler;
|
||||
}
|
||||
|
||||
export function loadConfig(): OpenClawConfig {
|
||||
if (runtimeConfigSnapshot) {
|
||||
return runtimeConfigSnapshot;
|
||||
@@ -1425,11 +1402,9 @@ export async function writeConfigFile(
|
||||
): Promise<void> {
|
||||
const io = createConfigIO();
|
||||
let nextCfg = cfg;
|
||||
const hadRuntimeSnapshot = Boolean(runtimeConfigSnapshot);
|
||||
const hadBothSnapshots = Boolean(runtimeConfigSnapshot && runtimeConfigSourceSnapshot);
|
||||
if (hadBothSnapshots) {
|
||||
const runtimePatch = createMergePatch(runtimeConfigSnapshot!, cfg);
|
||||
nextCfg = coerceConfig(applyMergePatch(runtimeConfigSourceSnapshot!, runtimePatch));
|
||||
if (runtimeConfigSnapshot && runtimeConfigSourceSnapshot) {
|
||||
const runtimePatch = createMergePatch(runtimeConfigSnapshot, cfg);
|
||||
nextCfg = coerceConfig(applyMergePatch(runtimeConfigSourceSnapshot, runtimePatch));
|
||||
}
|
||||
const sameConfigPath =
|
||||
options.expectedConfigPath === undefined || options.expectedConfigPath === io.configPath;
|
||||
@@ -1437,38 +1412,4 @@ export async function writeConfigFile(
|
||||
envSnapshotForRestore: sameConfigPath ? options.envSnapshotForRestore : undefined,
|
||||
unsetPaths: options.unsetPaths,
|
||||
});
|
||||
// Keep the last-known-good runtime snapshot active until the specialized refresh path
|
||||
// succeeds, so concurrent readers do not observe unresolved SecretRefs mid-refresh.
|
||||
const refreshHandler = runtimeConfigSnapshotRefreshHandler;
|
||||
if (refreshHandler) {
|
||||
try {
|
||||
const refreshed = await refreshHandler.refresh({ sourceConfig: nextCfg });
|
||||
if (refreshed) {
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
try {
|
||||
refreshHandler.clearOnRefreshFailure?.();
|
||||
} catch {
|
||||
// Keep the original refresh failure as the surfaced error.
|
||||
}
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
throw new ConfigRuntimeRefreshError(
|
||||
`Config was written to ${io.configPath}, but runtime snapshot refresh failed: ${detail}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
}
|
||||
if (hadBothSnapshots) {
|
||||
// Refresh both snapshots from disk atomically so follow-up reads get normalized config and
|
||||
// subsequent writes still get secret-preservation merge-patch (hadBothSnapshots stays true).
|
||||
const fresh = io.loadConfig();
|
||||
setRuntimeConfigSnapshot(fresh, nextCfg);
|
||||
return;
|
||||
}
|
||||
if (hadRuntimeSnapshot) {
|
||||
clearRuntimeConfigSnapshot();
|
||||
}
|
||||
// When we had no runtime snapshot, keep callers reading from disk/cache so external/manual
|
||||
// edits to openclaw.json remain visible (no stale snapshot).
|
||||
}
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const repoRoot = resolve(fileURLToPath(new URL(".", import.meta.url)), "..");
|
||||
|
||||
async function readRepoFile(path: string): Promise<string> {
|
||||
return readFile(resolve(repoRoot, path), "utf8");
|
||||
}
|
||||
|
||||
describe("docker build cache layout", () => {
|
||||
it("keeps the root dependency layer independent from scripts changes", async () => {
|
||||
const dockerfile = await readRepoFile("Dockerfile");
|
||||
const installIndex = dockerfile.indexOf("pnpm install --frozen-lockfile");
|
||||
const copyAllIndex = dockerfile.indexOf("COPY . .");
|
||||
const scriptsCopyIndex = dockerfile.indexOf("COPY scripts ./scripts");
|
||||
|
||||
expect(installIndex).toBeGreaterThan(-1);
|
||||
expect(copyAllIndex).toBeGreaterThan(installIndex);
|
||||
expect(scriptsCopyIndex === -1 || scriptsCopyIndex > installIndex).toBe(true);
|
||||
});
|
||||
|
||||
it("uses pnpm cache mounts in Dockerfiles that install repo dependencies", async () => {
|
||||
for (const path of [
|
||||
"Dockerfile",
|
||||
"scripts/e2e/Dockerfile",
|
||||
"scripts/e2e/Dockerfile.qr-import",
|
||||
"scripts/docker/cleanup-smoke/Dockerfile",
|
||||
]) {
|
||||
const dockerfile = await readRepoFile(path);
|
||||
expect(dockerfile, `${path} should use a shared pnpm store cache`).toContain(
|
||||
"--mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("uses apt cache mounts in Dockerfiles that install system packages", async () => {
|
||||
for (const path of [
|
||||
"Dockerfile",
|
||||
"Dockerfile.sandbox",
|
||||
"Dockerfile.sandbox-browser",
|
||||
"Dockerfile.sandbox-common",
|
||||
"scripts/docker/cleanup-smoke/Dockerfile",
|
||||
"scripts/docker/install-sh-smoke/Dockerfile",
|
||||
"scripts/docker/install-sh-e2e/Dockerfile",
|
||||
"scripts/docker/install-sh-nonroot/Dockerfile",
|
||||
]) {
|
||||
const dockerfile = await readRepoFile(path);
|
||||
expect(dockerfile, `${path} should cache apt package archives`).toContain(
|
||||
"target=/var/cache/apt,sharing=locked",
|
||||
);
|
||||
expect(dockerfile, `${path} should cache apt metadata`).toContain(
|
||||
"target=/var/lib/apt,sharing=locked",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not leave empty shell continuation lines in sandbox-common", async () => {
|
||||
const dockerfile = await readRepoFile("Dockerfile.sandbox-common");
|
||||
expect(dockerfile).not.toContain("apt-get install -y --no-install-recommends ${PACKAGES} \\");
|
||||
expect(dockerfile).toContain(
|
||||
'RUN if [ "${INSTALL_PNPM}" = "1" ]; then npm install -g pnpm; fi',
|
||||
);
|
||||
});
|
||||
|
||||
it("does not leave blank lines after shell continuation markers", async () => {
|
||||
for (const path of [
|
||||
"Dockerfile.sandbox",
|
||||
"Dockerfile.sandbox-browser",
|
||||
"Dockerfile.sandbox-common",
|
||||
"scripts/docker/cleanup-smoke/Dockerfile",
|
||||
"scripts/docker/install-sh-smoke/Dockerfile",
|
||||
"scripts/docker/install-sh-e2e/Dockerfile",
|
||||
"scripts/docker/install-sh-nonroot/Dockerfile",
|
||||
]) {
|
||||
const dockerfile = await readRepoFile(path);
|
||||
expect(
|
||||
dockerfile,
|
||||
`${path} should not have blank lines after a trailing backslash`,
|
||||
).not.toMatch(/\\\n\s*\n/);
|
||||
}
|
||||
});
|
||||
|
||||
it("copies only install inputs before pnpm install in the e2e image", async () => {
|
||||
const dockerfile = await readRepoFile("scripts/e2e/Dockerfile");
|
||||
const installIndex = dockerfile.indexOf("pnpm install --frozen-lockfile");
|
||||
|
||||
expect(
|
||||
dockerfile.indexOf("COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./"),
|
||||
).toBeLessThan(installIndex);
|
||||
expect(dockerfile.indexOf("COPY ui/package.json ./ui/package.json")).toBeLessThan(installIndex);
|
||||
expect(
|
||||
dockerfile.indexOf(
|
||||
"COPY extensions/memory-core/package.json ./extensions/memory-core/package.json",
|
||||
),
|
||||
).toBeLessThan(installIndex);
|
||||
expect(
|
||||
dockerfile.indexOf(
|
||||
"COPY tsconfig.json tsconfig.plugin-sdk.dts.json tsdown.config.ts vitest.config.ts vitest.e2e.config.ts openclaw.mjs ./",
|
||||
),
|
||||
).toBeGreaterThan(installIndex);
|
||||
expect(dockerfile.indexOf("COPY src ./src")).toBeGreaterThan(installIndex);
|
||||
expect(dockerfile.indexOf("COPY test ./test")).toBeGreaterThan(installIndex);
|
||||
expect(dockerfile.indexOf("COPY scripts ./scripts")).toBeGreaterThan(installIndex);
|
||||
expect(dockerfile.indexOf("COPY ui ./ui")).toBeGreaterThan(installIndex);
|
||||
});
|
||||
|
||||
it("copies manifests before install in the qr-import image", async () => {
|
||||
const dockerfile = await readRepoFile("scripts/e2e/Dockerfile.qr-import");
|
||||
const installIndex = dockerfile.indexOf("pnpm install --frozen-lockfile");
|
||||
|
||||
expect(
|
||||
dockerfile.indexOf("COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./"),
|
||||
).toBeLessThan(installIndex);
|
||||
expect(dockerfile.indexOf("COPY ui/package.json ./ui/package.json")).toBeLessThan(installIndex);
|
||||
expect(dockerfile).toContain(
|
||||
"This image only exercises the root qrcode-terminal dependency path.",
|
||||
);
|
||||
expect(
|
||||
dockerfile.indexOf(
|
||||
"COPY extensions/memory-core/package.json ./extensions/memory-core/package.json",
|
||||
),
|
||||
).toBe(-1);
|
||||
expect(dockerfile.indexOf("COPY . .")).toBeGreaterThan(installIndex);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,6 @@ import net from "node:net";
|
||||
import path from "node:path";
|
||||
import { resolveConfigPath, resolveGatewayLockDir, resolveStateDir } from "../config/paths.js";
|
||||
import { isPidAlive } from "../shared/pid-alive.js";
|
||||
import { isGatewayArgv, parseProcCmdline } from "./gateway-process-argv.js";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 5000;
|
||||
const DEFAULT_POLL_INTERVAL_MS = 100;
|
||||
@@ -47,6 +46,38 @@ export class GatewayLockError extends Error {
|
||||
|
||||
type LockOwnerStatus = "alive" | "dead" | "unknown";
|
||||
|
||||
function normalizeProcArg(arg: string): string {
|
||||
return arg.replaceAll("\\", "/").toLowerCase();
|
||||
}
|
||||
|
||||
function parseProcCmdline(raw: string): string[] {
|
||||
return raw
|
||||
.split("\0")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function isGatewayArgv(args: string[]): boolean {
|
||||
const normalized = args.map(normalizeProcArg);
|
||||
if (!normalized.includes("gateway")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const entryCandidates = [
|
||||
"dist/index.js",
|
||||
"dist/entry.js",
|
||||
"openclaw.mjs",
|
||||
"scripts/run-node.mjs",
|
||||
"src/index.ts",
|
||||
];
|
||||
if (normalized.some((arg) => entryCandidates.some((entry) => arg.endsWith(entry)))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const exe = normalized[0] ?? "";
|
||||
return exe.endsWith("/openclaw") || exe === "openclaw";
|
||||
}
|
||||
|
||||
function readLinuxCmdline(pid: number): string[] | null {
|
||||
try {
|
||||
const raw = fsSync.readFileSync(`/proc/${pid}/cmdline`, "utf8");
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
function normalizeProcArg(arg: string): string {
|
||||
return arg.replaceAll("\\", "/").toLowerCase();
|
||||
}
|
||||
|
||||
export function parseProcCmdline(raw: string): string[] {
|
||||
return raw
|
||||
.split("\0")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function isGatewayArgv(args: string[], opts?: { allowGatewayBinary?: boolean }): boolean {
|
||||
const normalized = args.map(normalizeProcArg);
|
||||
if (!normalized.includes("gateway")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const entryCandidates = [
|
||||
"dist/index.js",
|
||||
"dist/entry.js",
|
||||
"openclaw.mjs",
|
||||
"scripts/run-node.mjs",
|
||||
"src/index.ts",
|
||||
];
|
||||
if (normalized.some((arg) => entryCandidates.some((entry) => arg.endsWith(entry)))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const exe = (normalized[0] ?? "").replace(/\.(bat|cmd|exe)$/i, "");
|
||||
return (
|
||||
exe.endsWith("/openclaw") ||
|
||||
exe === "openclaw" ||
|
||||
(opts?.allowGatewayBinary === true && exe.endsWith("/openclaw-gateway"))
|
||||
);
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const controlServiceMocks = vi.hoisted(() => ({
|
||||
createBrowserControlContext: vi.fn(() => ({ control: true })),
|
||||
startBrowserControlServiceFromConfig: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
const dispatcherMocks = vi.hoisted(() => ({
|
||||
dispatch: vi.fn(),
|
||||
createBrowserRouteDispatcher: vi.fn(() => ({
|
||||
dispatch: dispatcherMocks.dispatch,
|
||||
})),
|
||||
}));
|
||||
|
||||
const configMocks = vi.hoisted(() => ({
|
||||
loadConfig: vi.fn(() => ({
|
||||
browser: {},
|
||||
nodeHost: { browserProxy: { enabled: true } },
|
||||
})),
|
||||
}));
|
||||
|
||||
const browserConfigMocks = vi.hoisted(() => ({
|
||||
resolveBrowserConfig: vi.fn(() => ({
|
||||
enabled: true,
|
||||
defaultProfile: "chrome",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../browser/control-service.js", () => controlServiceMocks);
|
||||
vi.mock("../browser/routes/dispatcher.js", () => dispatcherMocks);
|
||||
vi.mock("../config/config.js", () => configMocks);
|
||||
vi.mock("../browser/config.js", () => browserConfigMocks);
|
||||
vi.mock("../media/mime.js", () => ({
|
||||
detectMime: vi.fn(async () => "image/png"),
|
||||
}));
|
||||
|
||||
import { runBrowserProxyCommand } from "./invoke-browser.js";
|
||||
|
||||
describe("runBrowserProxyCommand", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
configMocks.loadConfig.mockReturnValue({
|
||||
browser: {},
|
||||
nodeHost: { browserProxy: { enabled: true } },
|
||||
});
|
||||
browserConfigMocks.resolveBrowserConfig.mockReturnValue({
|
||||
enabled: true,
|
||||
defaultProfile: "chrome",
|
||||
});
|
||||
controlServiceMocks.startBrowserControlServiceFromConfig.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("adds profile and browser status details on ws-backed timeouts", async () => {
|
||||
dispatcherMocks.dispatch
|
||||
.mockImplementationOnce(async () => {
|
||||
await new Promise(() => {});
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
body: {
|
||||
running: true,
|
||||
cdpHttp: true,
|
||||
cdpReady: false,
|
||||
cdpUrl: "http://127.0.0.1:18792",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
runBrowserProxyCommand(
|
||||
JSON.stringify({
|
||||
method: "GET",
|
||||
path: "/snapshot",
|
||||
profile: "chrome",
|
||||
timeoutMs: 5,
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow(
|
||||
/browser proxy timed out for GET \/snapshot after 5ms; ws-backed browser action; profile=chrome; status\(running=true, cdpHttp=true, cdpReady=false, cdpUrl=http:\/\/127\.0\.0\.1:18792\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps non-timeout browser errors intact", async () => {
|
||||
dispatcherMocks.dispatch.mockResolvedValue({
|
||||
status: 500,
|
||||
body: { error: "tab not found" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
runBrowserProxyCommand(
|
||||
JSON.stringify({
|
||||
method: "POST",
|
||||
path: "/act",
|
||||
profile: "chrome",
|
||||
timeoutMs: 50,
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow("tab not found");
|
||||
});
|
||||
});
|
||||
@@ -30,8 +30,6 @@ type BrowserProxyResult = {
|
||||
};
|
||||
|
||||
const BROWSER_PROXY_MAX_FILE_BYTES = 10 * 1024 * 1024;
|
||||
const DEFAULT_BROWSER_PROXY_TIMEOUT_MS = 20_000;
|
||||
const BROWSER_PROXY_STATUS_TIMEOUT_MS = 750;
|
||||
|
||||
function normalizeProfileAllowlist(raw?: string[]): string[] {
|
||||
return Array.isArray(raw) ? raw.map((entry) => entry.trim()).filter(Boolean) : [];
|
||||
@@ -121,87 +119,6 @@ function decodeParams<T>(raw?: string | null): T {
|
||||
return JSON.parse(raw) as T;
|
||||
}
|
||||
|
||||
function resolveBrowserProxyTimeout(timeoutMs?: number): number {
|
||||
return typeof timeoutMs === "number" && Number.isFinite(timeoutMs)
|
||||
? Math.max(1, Math.floor(timeoutMs))
|
||||
: DEFAULT_BROWSER_PROXY_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
function isBrowserProxyTimeoutError(err: unknown): boolean {
|
||||
return String(err).includes("browser proxy request timed out");
|
||||
}
|
||||
|
||||
function isWsBackedBrowserProxyPath(path: string): boolean {
|
||||
return (
|
||||
path === "/act" ||
|
||||
path === "/navigate" ||
|
||||
path === "/pdf" ||
|
||||
path === "/screenshot" ||
|
||||
path === "/snapshot"
|
||||
);
|
||||
}
|
||||
|
||||
async function readBrowserProxyStatus(params: {
|
||||
dispatcher: ReturnType<typeof createBrowserRouteDispatcher>;
|
||||
profile?: string;
|
||||
}): Promise<Record<string, unknown> | null> {
|
||||
const query = params.profile ? { profile: params.profile } : {};
|
||||
try {
|
||||
const response = await withTimeout(
|
||||
(signal) =>
|
||||
params.dispatcher.dispatch({
|
||||
method: "GET",
|
||||
path: "/",
|
||||
query,
|
||||
signal,
|
||||
}),
|
||||
BROWSER_PROXY_STATUS_TIMEOUT_MS,
|
||||
"browser proxy status",
|
||||
);
|
||||
if (response.status >= 400 || !response.body || typeof response.body !== "object") {
|
||||
return null;
|
||||
}
|
||||
const body = response.body as Record<string, unknown>;
|
||||
return {
|
||||
running: body.running,
|
||||
cdpHttp: body.cdpHttp,
|
||||
cdpReady: body.cdpReady,
|
||||
cdpUrl: body.cdpUrl,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatBrowserProxyTimeoutMessage(params: {
|
||||
method: string;
|
||||
path: string;
|
||||
profile?: string;
|
||||
timeoutMs: number;
|
||||
wsBacked: boolean;
|
||||
status: Record<string, unknown> | null;
|
||||
}): string {
|
||||
const parts = [
|
||||
`browser proxy timed out for ${params.method} ${params.path} after ${params.timeoutMs}ms`,
|
||||
params.wsBacked ? "ws-backed browser action" : "browser action",
|
||||
];
|
||||
if (params.profile) {
|
||||
parts.push(`profile=${params.profile}`);
|
||||
}
|
||||
if (params.status) {
|
||||
const statusParts = [
|
||||
`running=${String(params.status.running)}`,
|
||||
`cdpHttp=${String(params.status.cdpHttp)}`,
|
||||
`cdpReady=${String(params.status.cdpReady)}`,
|
||||
];
|
||||
if (typeof params.status.cdpUrl === "string" && params.status.cdpUrl.trim()) {
|
||||
statusParts.push(`cdpUrl=${params.status.cdpUrl}`);
|
||||
}
|
||||
parts.push(`status(${statusParts.join(", ")})`);
|
||||
}
|
||||
return parts.join("; ");
|
||||
}
|
||||
|
||||
export async function runBrowserProxyCommand(paramsJSON?: string | null): Promise<string> {
|
||||
const params = decodeParams<BrowserProxyParams>(paramsJSON);
|
||||
const pathValue = typeof params.path === "string" ? params.path.trim() : "";
|
||||
@@ -234,7 +151,6 @@ export async function runBrowserProxyCommand(paramsJSON?: string | null): Promis
|
||||
const method = typeof params.method === "string" ? params.method.toUpperCase() : "GET";
|
||||
const path = pathValue.startsWith("/") ? pathValue : `/${pathValue}`;
|
||||
const body = params.body;
|
||||
const timeoutMs = resolveBrowserProxyTimeout(params.timeoutMs);
|
||||
const query: Record<string, unknown> = {};
|
||||
if (requestedProfile) {
|
||||
query.profile = requestedProfile;
|
||||
@@ -248,41 +164,18 @@ export async function runBrowserProxyCommand(paramsJSON?: string | null): Promis
|
||||
}
|
||||
|
||||
const dispatcher = createBrowserRouteDispatcher(createBrowserControlContext());
|
||||
let response;
|
||||
try {
|
||||
response = await withTimeout(
|
||||
(signal) =>
|
||||
dispatcher.dispatch({
|
||||
method: method === "DELETE" ? "DELETE" : method === "POST" ? "POST" : "GET",
|
||||
path,
|
||||
query,
|
||||
body,
|
||||
signal,
|
||||
}),
|
||||
timeoutMs,
|
||||
"browser proxy request",
|
||||
);
|
||||
} catch (err) {
|
||||
if (!isBrowserProxyTimeoutError(err)) {
|
||||
throw err;
|
||||
}
|
||||
const profileForStatus = requestedProfile || resolved.defaultProfile;
|
||||
const status = await readBrowserProxyStatus({
|
||||
dispatcher,
|
||||
profile: path === "/profiles" ? undefined : profileForStatus,
|
||||
});
|
||||
throw new Error(
|
||||
formatBrowserProxyTimeoutMessage({
|
||||
method,
|
||||
const response = await withTimeout(
|
||||
(signal) =>
|
||||
dispatcher.dispatch({
|
||||
method: method === "DELETE" ? "DELETE" : method === "POST" ? "POST" : "GET",
|
||||
path,
|
||||
profile: path === "/profiles" ? undefined : profileForStatus || undefined,
|
||||
timeoutMs,
|
||||
wsBacked: isWsBackedBrowserProxyPath(path),
|
||||
status,
|
||||
query,
|
||||
body,
|
||||
signal,
|
||||
}),
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
params.timeoutMs,
|
||||
"browser proxy request",
|
||||
);
|
||||
if (response.status >= 400) {
|
||||
const message =
|
||||
response.body && typeof response.body === "object" && "error" in response.body
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import {
|
||||
deleteAccountFromConfigSection,
|
||||
setAccountEnabledInConfigSection,
|
||||
} from "../channels/plugins/config-helpers.js";
|
||||
import { normalizeWhatsAppAllowFromEntries } from "../channels/plugins/normalize/whatsapp.js";
|
||||
import type { ChannelConfigAdapter } from "../channels/plugins/types.adapters.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
@@ -59,51 +55,6 @@ export function createScopedAccountConfigAccessors<ResolvedAccount>(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export function createScopedChannelConfigBase<
|
||||
ResolvedAccount,
|
||||
Config extends OpenClawConfig = OpenClawConfig,
|
||||
>(params: {
|
||||
sectionKey: string;
|
||||
listAccountIds: (cfg: Config) => string[];
|
||||
resolveAccount: (cfg: Config, accountId?: string | null) => ResolvedAccount;
|
||||
defaultAccountId: (cfg: Config) => string;
|
||||
inspectAccount?: (cfg: Config, accountId?: string | null) => unknown;
|
||||
clearBaseFields: string[];
|
||||
allowTopLevel?: boolean;
|
||||
}): Pick<
|
||||
ChannelConfigAdapter<ResolvedAccount>,
|
||||
| "listAccountIds"
|
||||
| "resolveAccount"
|
||||
| "inspectAccount"
|
||||
| "defaultAccountId"
|
||||
| "setAccountEnabled"
|
||||
| "deleteAccount"
|
||||
> {
|
||||
return {
|
||||
listAccountIds: (cfg) => params.listAccountIds(cfg as Config),
|
||||
resolveAccount: (cfg, accountId) => params.resolveAccount(cfg as Config, accountId),
|
||||
inspectAccount: params.inspectAccount
|
||||
? (cfg, accountId) => params.inspectAccount?.(cfg as Config, accountId)
|
||||
: undefined,
|
||||
defaultAccountId: (cfg) => params.defaultAccountId(cfg as Config),
|
||||
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
||||
setAccountEnabledInConfigSection({
|
||||
cfg: cfg as Config,
|
||||
sectionKey: params.sectionKey,
|
||||
accountId,
|
||||
enabled,
|
||||
allowTopLevel: params.allowTopLevel ?? true,
|
||||
}),
|
||||
deleteAccount: ({ cfg, accountId }) =>
|
||||
deleteAccountFromConfigSection({
|
||||
cfg: cfg as Config,
|
||||
sectionKey: params.sectionKey,
|
||||
accountId,
|
||||
clearBaseFields: params.clearBaseFields,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveWhatsAppConfigAllowFrom(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
|
||||
@@ -194,12 +194,6 @@ export { buildOauthProviderAuthResult } from "./provider-auth-result.js";
|
||||
export { formatResolvedUnresolvedNote } from "./resolution-notes.js";
|
||||
export { buildChannelSendResult } from "./channel-send-result.js";
|
||||
export type { ChannelSendRawResult } from "./channel-send-result.js";
|
||||
export { createPluginRuntimeStore } from "./runtime-store.js";
|
||||
export { createScopedChannelConfigBase } from "./channel-config-helpers.js";
|
||||
export {
|
||||
AllowFromEntrySchema,
|
||||
buildCatchallMultiAccountChannelSchema,
|
||||
} from "../channels/plugins/config-schema.js";
|
||||
export type { ChannelDock } from "../channels/dock.js";
|
||||
export { getChatChannelMeta } from "../channels/registry.js";
|
||||
export { resolveAllowlistMatchByCandidates } from "../channels/allowlist-match.js";
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
export function createPluginRuntimeStore<T>(errorMessage: string): {
|
||||
setRuntime: (next: T) => void;
|
||||
clearRuntime: () => void;
|
||||
tryGetRuntime: () => T | null;
|
||||
getRuntime: () => T;
|
||||
} {
|
||||
let runtime: T | null = null;
|
||||
|
||||
return {
|
||||
setRuntime(next: T) {
|
||||
runtime = next;
|
||||
},
|
||||
clearRuntime() {
|
||||
runtime = null;
|
||||
},
|
||||
tryGetRuntime() {
|
||||
return runtime;
|
||||
},
|
||||
getRuntime() {
|
||||
if (!runtime) {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
return runtime;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -3,12 +3,10 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { ensureAuthProfileStore, type AuthProfileStore } from "../agents/auth-profiles.js";
|
||||
import { loadConfig, type OpenClawConfig, writeConfigFile } from "../config/config.js";
|
||||
import { withTempHome } from "../config/home-env.test-harness.js";
|
||||
import { loadConfig, type OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
activateSecretsRuntimeSnapshot,
|
||||
clearSecretsRuntimeSnapshot,
|
||||
getActiveSecretsRuntimeSnapshot,
|
||||
prepareSecretsRuntimeSnapshot,
|
||||
} from "./runtime.js";
|
||||
|
||||
@@ -529,248 +527,6 @@ describe("secrets runtime snapshot", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps active secrets runtime snapshots resolved after config writes", async () => {
|
||||
await withTempHome("openclaw-secrets-runtime-write-", async (home) => {
|
||||
const configDir = path.join(home, ".openclaw");
|
||||
const secretFile = path.join(configDir, "secrets.json");
|
||||
const agentDir = path.join(configDir, "agents", "main", "agent");
|
||||
const authStorePath = path.join(agentDir, "auth-profiles.json");
|
||||
await fs.mkdir(agentDir, { recursive: true });
|
||||
await fs.chmod(configDir, 0o700).catch(() => {
|
||||
// best-effort on tmp dirs that already have secure perms
|
||||
});
|
||||
await fs.writeFile(
|
||||
secretFile,
|
||||
`${JSON.stringify({ providers: { openai: { apiKey: "sk-file-runtime" } } }, null, 2)}\n`, // pragma: allowlist secret
|
||||
{ encoding: "utf8", mode: 0o600 },
|
||||
);
|
||||
await fs.writeFile(
|
||||
authStorePath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai:default": {
|
||||
type: "api_key",
|
||||
provider: "openai",
|
||||
keyRef: { source: "file", provider: "default", id: "/providers/openai/apiKey" },
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
{ encoding: "utf8", mode: 0o600 },
|
||||
);
|
||||
|
||||
const prepared = await prepareSecretsRuntimeSnapshot({
|
||||
config: asConfig({
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "file", path: secretFile, mode: "json" },
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
apiKey: { source: "file", provider: "default", id: "/providers/openai/apiKey" },
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
agentDirs: [agentDir],
|
||||
});
|
||||
|
||||
activateSecretsRuntimeSnapshot(prepared);
|
||||
|
||||
expect(loadConfig().models?.providers?.openai?.apiKey).toBe("sk-file-runtime");
|
||||
expect(ensureAuthProfileStore(agentDir).profiles["openai:default"]).toMatchObject({
|
||||
type: "api_key",
|
||||
key: "sk-file-runtime",
|
||||
});
|
||||
|
||||
await writeConfigFile({
|
||||
...loadConfig(),
|
||||
gateway: { auth: { mode: "token" } },
|
||||
});
|
||||
|
||||
expect(loadConfig().gateway?.auth).toEqual({ mode: "token" });
|
||||
expect(loadConfig().models?.providers?.openai?.apiKey).toBe("sk-file-runtime");
|
||||
expect(ensureAuthProfileStore(agentDir).profiles["openai:default"]).toMatchObject({
|
||||
type: "api_key",
|
||||
key: "sk-file-runtime",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("clears active secrets runtime state and throws when refresh fails after a write", async () => {
|
||||
await withTempHome("openclaw-secrets-runtime-refresh-fail-", async (home) => {
|
||||
const configDir = path.join(home, ".openclaw");
|
||||
const secretFile = path.join(configDir, "secrets.json");
|
||||
const agentDir = path.join(configDir, "agents", "main", "agent");
|
||||
const authStorePath = path.join(agentDir, "auth-profiles.json");
|
||||
await fs.mkdir(agentDir, { recursive: true });
|
||||
await fs.chmod(configDir, 0o700).catch(() => {
|
||||
// best-effort on tmp dirs that already have secure perms
|
||||
});
|
||||
await fs.writeFile(
|
||||
secretFile,
|
||||
`${JSON.stringify({ providers: { openai: { apiKey: "sk-file-runtime" } } }, null, 2)}\n`, // pragma: allowlist secret
|
||||
{ encoding: "utf8", mode: 0o600 },
|
||||
);
|
||||
await fs.writeFile(
|
||||
authStorePath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai:default": {
|
||||
type: "api_key",
|
||||
provider: "openai",
|
||||
keyRef: { source: "file", provider: "default", id: "/providers/openai/apiKey" },
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
{ encoding: "utf8", mode: 0o600 },
|
||||
);
|
||||
|
||||
let loadAuthStoreCalls = 0;
|
||||
const loadAuthStore = () => {
|
||||
loadAuthStoreCalls += 1;
|
||||
if (loadAuthStoreCalls > 1) {
|
||||
throw new Error("simulated secrets runtime refresh failure");
|
||||
}
|
||||
return loadAuthStoreWithProfiles({
|
||||
"openai:default": {
|
||||
type: "api_key",
|
||||
provider: "openai",
|
||||
keyRef: { source: "file", provider: "default", id: "/providers/openai/apiKey" },
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const prepared = await prepareSecretsRuntimeSnapshot({
|
||||
config: asConfig({
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "file", path: secretFile, mode: "json" },
|
||||
},
|
||||
},
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
apiKey: { source: "file", provider: "default", id: "/providers/openai/apiKey" },
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
agentDirs: [agentDir],
|
||||
loadAuthStore,
|
||||
});
|
||||
|
||||
activateSecretsRuntimeSnapshot(prepared);
|
||||
|
||||
await expect(
|
||||
writeConfigFile({
|
||||
...loadConfig(),
|
||||
gateway: { auth: { mode: "token" } },
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
/runtime snapshot refresh failed: simulated secrets runtime refresh failure/i,
|
||||
);
|
||||
|
||||
expect(getActiveSecretsRuntimeSnapshot()).toBeNull();
|
||||
expect(loadConfig().gateway?.auth).toEqual({ mode: "token" });
|
||||
expect(loadConfig().models?.providers?.openai?.apiKey).toEqual({
|
||||
source: "file",
|
||||
provider: "default",
|
||||
id: "/providers/openai/apiKey",
|
||||
});
|
||||
|
||||
const persistedStore = ensureAuthProfileStore(agentDir).profiles["openai:default"];
|
||||
expect(persistedStore).toMatchObject({
|
||||
type: "api_key",
|
||||
keyRef: { source: "file", provider: "default", id: "/providers/openai/apiKey" },
|
||||
});
|
||||
expect("key" in persistedStore ? persistedStore.key : undefined).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("recomputes config-derived agent dirs when refreshing active secrets runtime snapshots", async () => {
|
||||
await withTempHome("openclaw-secrets-runtime-agent-dirs-", async (home) => {
|
||||
const mainAgentDir = path.join(home, ".openclaw", "agents", "main", "agent");
|
||||
const opsAgentDir = path.join(home, ".openclaw", "agents", "ops", "agent");
|
||||
await fs.mkdir(mainAgentDir, { recursive: true });
|
||||
await fs.mkdir(opsAgentDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(mainAgentDir, "auth-profiles.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai:default": {
|
||||
type: "api_key",
|
||||
provider: "openai",
|
||||
keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
{ encoding: "utf8", mode: 0o600 },
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(opsAgentDir, "auth-profiles.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
profiles: {
|
||||
"anthropic:ops": {
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
keyRef: { source: "env", provider: "default", id: "ANTHROPIC_API_KEY" },
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
{ encoding: "utf8", mode: 0o600 },
|
||||
);
|
||||
|
||||
const prepared = await prepareSecretsRuntimeSnapshot({
|
||||
config: asConfig({}),
|
||||
env: {
|
||||
OPENAI_API_KEY: "sk-main-runtime", // pragma: allowlist secret
|
||||
ANTHROPIC_API_KEY: "sk-ops-runtime", // pragma: allowlist secret
|
||||
},
|
||||
});
|
||||
|
||||
activateSecretsRuntimeSnapshot(prepared);
|
||||
expect(ensureAuthProfileStore(opsAgentDir).profiles["anthropic:ops"]).toBeUndefined();
|
||||
|
||||
await writeConfigFile({
|
||||
agents: {
|
||||
list: [{ id: "ops", agentDir: opsAgentDir }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(ensureAuthProfileStore(opsAgentDir).profiles["anthropic:ops"]).toMatchObject({
|
||||
type: "api_key",
|
||||
key: "sk-ops-runtime",
|
||||
keyRef: { source: "env", provider: "default", id: "ANTHROPIC_API_KEY" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("skips inactive-surface refs and emits diagnostics", async () => {
|
||||
const config = asConfig({
|
||||
agents: {
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
} from "../agents/auth-profiles.js";
|
||||
import {
|
||||
clearRuntimeConfigSnapshot,
|
||||
setRuntimeConfigSnapshotRefreshHandler,
|
||||
setRuntimeConfigSnapshot,
|
||||
type OpenClawConfig,
|
||||
} from "../config/config.js";
|
||||
@@ -35,18 +34,7 @@ export type PreparedSecretsRuntimeSnapshot = {
|
||||
warnings: SecretResolverWarning[];
|
||||
};
|
||||
|
||||
type SecretsRuntimeRefreshContext = {
|
||||
env: Record<string, string | undefined>;
|
||||
explicitAgentDirs: string[] | null;
|
||||
loadAuthStore: (agentDir?: string) => AuthProfileStore;
|
||||
};
|
||||
|
||||
let activeSnapshot: PreparedSecretsRuntimeSnapshot | null = null;
|
||||
let activeRefreshContext: SecretsRuntimeRefreshContext | null = null;
|
||||
const preparedSnapshotRefreshContext = new WeakMap<
|
||||
PreparedSecretsRuntimeSnapshot,
|
||||
SecretsRuntimeRefreshContext
|
||||
>();
|
||||
|
||||
function cloneSnapshot(snapshot: PreparedSecretsRuntimeSnapshot): PreparedSecretsRuntimeSnapshot {
|
||||
return {
|
||||
@@ -60,22 +48,6 @@ function cloneSnapshot(snapshot: PreparedSecretsRuntimeSnapshot): PreparedSecret
|
||||
};
|
||||
}
|
||||
|
||||
function cloneRefreshContext(context: SecretsRuntimeRefreshContext): SecretsRuntimeRefreshContext {
|
||||
return {
|
||||
env: { ...context.env },
|
||||
explicitAgentDirs: context.explicitAgentDirs ? [...context.explicitAgentDirs] : null,
|
||||
loadAuthStore: context.loadAuthStore,
|
||||
};
|
||||
}
|
||||
|
||||
function clearActiveSecretsRuntimeState(): void {
|
||||
activeSnapshot = null;
|
||||
activeRefreshContext = null;
|
||||
setRuntimeConfigSnapshotRefreshHandler(null);
|
||||
clearRuntimeConfigSnapshot();
|
||||
clearRuntimeAuthProfileStoreSnapshots();
|
||||
}
|
||||
|
||||
function collectCandidateAgentDirs(config: OpenClawConfig): string[] {
|
||||
const dirs = new Set<string>();
|
||||
dirs.add(resolveUserPath(resolveOpenClawAgentDir()));
|
||||
@@ -85,17 +57,6 @@ function collectCandidateAgentDirs(config: OpenClawConfig): string[] {
|
||||
return [...dirs];
|
||||
}
|
||||
|
||||
function resolveRefreshAgentDirs(
|
||||
config: OpenClawConfig,
|
||||
context: SecretsRuntimeRefreshContext,
|
||||
): string[] {
|
||||
const configDerived = collectCandidateAgentDirs(config);
|
||||
if (!context.explicitAgentDirs || context.explicitAgentDirs.length === 0) {
|
||||
return configDerived;
|
||||
}
|
||||
return [...new Set([...context.explicitAgentDirs, ...configDerived])];
|
||||
}
|
||||
|
||||
export async function prepareSecretsRuntimeSnapshot(params: {
|
||||
config: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
@@ -143,61 +104,23 @@ export async function prepareSecretsRuntimeSnapshot(params: {
|
||||
});
|
||||
}
|
||||
|
||||
const snapshot = {
|
||||
return {
|
||||
sourceConfig,
|
||||
config: resolvedConfig,
|
||||
authStores,
|
||||
warnings: context.warnings,
|
||||
};
|
||||
preparedSnapshotRefreshContext.set(snapshot, {
|
||||
env: { ...(params.env ?? process.env) } as Record<string, string | undefined>,
|
||||
explicitAgentDirs: params.agentDirs?.length ? [...candidateDirs] : null,
|
||||
loadAuthStore,
|
||||
});
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function activateSecretsRuntimeSnapshot(snapshot: PreparedSecretsRuntimeSnapshot): void {
|
||||
const next = cloneSnapshot(snapshot);
|
||||
const refreshContext =
|
||||
preparedSnapshotRefreshContext.get(snapshot) ??
|
||||
activeRefreshContext ??
|
||||
({
|
||||
env: { ...process.env } as Record<string, string | undefined>,
|
||||
explicitAgentDirs: null,
|
||||
loadAuthStore: loadAuthProfileStoreForSecretsRuntime,
|
||||
} satisfies SecretsRuntimeRefreshContext);
|
||||
setRuntimeConfigSnapshot(next.config, next.sourceConfig);
|
||||
replaceRuntimeAuthProfileStoreSnapshots(next.authStores);
|
||||
activeSnapshot = next;
|
||||
activeRefreshContext = cloneRefreshContext(refreshContext);
|
||||
setRuntimeConfigSnapshotRefreshHandler({
|
||||
refresh: async ({ sourceConfig }) => {
|
||||
if (!activeSnapshot || !activeRefreshContext) {
|
||||
return false;
|
||||
}
|
||||
const refreshed = await prepareSecretsRuntimeSnapshot({
|
||||
config: sourceConfig,
|
||||
env: activeRefreshContext.env,
|
||||
agentDirs: resolveRefreshAgentDirs(sourceConfig, activeRefreshContext),
|
||||
loadAuthStore: activeRefreshContext.loadAuthStore,
|
||||
});
|
||||
activateSecretsRuntimeSnapshot(refreshed);
|
||||
return true;
|
||||
},
|
||||
clearOnRefreshFailure: clearActiveSecretsRuntimeState,
|
||||
});
|
||||
}
|
||||
|
||||
export function getActiveSecretsRuntimeSnapshot(): PreparedSecretsRuntimeSnapshot | null {
|
||||
if (!activeSnapshot) {
|
||||
return null;
|
||||
}
|
||||
const snapshot = cloneSnapshot(activeSnapshot);
|
||||
if (activeRefreshContext) {
|
||||
preparedSnapshotRefreshContext.set(snapshot, cloneRefreshContext(activeRefreshContext));
|
||||
}
|
||||
return snapshot;
|
||||
return activeSnapshot ? cloneSnapshot(activeSnapshot) : null;
|
||||
}
|
||||
|
||||
export function resolveCommandSecretsFromActiveRuntimeSnapshot(params: {
|
||||
@@ -232,5 +155,7 @@ export function resolveCommandSecretsFromActiveRuntimeSnapshot(params: {
|
||||
}
|
||||
|
||||
export function clearSecretsRuntimeSnapshot(): void {
|
||||
clearActiveSecretsRuntimeState();
|
||||
activeSnapshot = null;
|
||||
clearRuntimeConfigSnapshot();
|
||||
clearRuntimeAuthProfileStoreSnapshots();
|
||||
}
|
||||
|
||||
@@ -11,39 +11,6 @@ import { whatsappInboundLog } from "../loggers.js";
|
||||
import type { WebInboundMsg } from "../types.js";
|
||||
import type { GroupHistoryEntry } from "./process-message.js";
|
||||
|
||||
function buildBroadcastRouteKeys(params: {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
msg: WebInboundMsg;
|
||||
route: ReturnType<typeof resolveAgentRoute>;
|
||||
peerId: string;
|
||||
agentId: string;
|
||||
}) {
|
||||
const sessionKey = buildAgentSessionKey({
|
||||
agentId: params.agentId,
|
||||
channel: "whatsapp",
|
||||
accountId: params.route.accountId,
|
||||
peer: {
|
||||
kind: params.msg.chatType === "group" ? "group" : "direct",
|
||||
id: params.peerId,
|
||||
},
|
||||
dmScope: params.cfg.session?.dmScope,
|
||||
identityLinks: params.cfg.session?.identityLinks,
|
||||
});
|
||||
const mainSessionKey = buildAgentMainSessionKey({
|
||||
agentId: params.agentId,
|
||||
mainKey: DEFAULT_MAIN_KEY,
|
||||
});
|
||||
|
||||
return {
|
||||
sessionKey,
|
||||
mainSessionKey,
|
||||
lastRoutePolicy: deriveLastRoutePolicy({
|
||||
sessionKey,
|
||||
mainSessionKey,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function maybeBroadcastMessage(params: {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
msg: WebInboundMsg;
|
||||
@@ -85,17 +52,41 @@ export async function maybeBroadcastMessage(params: {
|
||||
whatsappInboundLog.warn(`Broadcast agent ${agentId} not found in agents.list; skipping`);
|
||||
return false;
|
||||
}
|
||||
const routeKeys = buildBroadcastRouteKeys({
|
||||
cfg: params.cfg,
|
||||
msg: params.msg,
|
||||
route: params.route,
|
||||
peerId: params.peerId,
|
||||
agentId: normalizedAgentId,
|
||||
});
|
||||
const agentRoute = {
|
||||
...params.route,
|
||||
agentId: normalizedAgentId,
|
||||
...routeKeys,
|
||||
sessionKey: buildAgentSessionKey({
|
||||
agentId: normalizedAgentId,
|
||||
channel: "whatsapp",
|
||||
accountId: params.route.accountId,
|
||||
peer: {
|
||||
kind: params.msg.chatType === "group" ? "group" : "direct",
|
||||
id: params.peerId,
|
||||
},
|
||||
dmScope: params.cfg.session?.dmScope,
|
||||
identityLinks: params.cfg.session?.identityLinks,
|
||||
}),
|
||||
mainSessionKey: buildAgentMainSessionKey({
|
||||
agentId: normalizedAgentId,
|
||||
mainKey: DEFAULT_MAIN_KEY,
|
||||
}),
|
||||
lastRoutePolicy: deriveLastRoutePolicy({
|
||||
sessionKey: buildAgentSessionKey({
|
||||
agentId: normalizedAgentId,
|
||||
channel: "whatsapp",
|
||||
accountId: params.route.accountId,
|
||||
peer: {
|
||||
kind: params.msg.chatType === "group" ? "group" : "direct",
|
||||
id: params.peerId,
|
||||
},
|
||||
dmScope: params.cfg.session?.dmScope,
|
||||
identityLinks: params.cfg.session?.identityLinks,
|
||||
}),
|
||||
mainSessionKey: buildAgentMainSessionKey({
|
||||
agentId: normalizedAgentId,
|
||||
mainKey: DEFAULT_MAIN_KEY,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user