Compare commits

..

1 Commits

Author SHA1 Message Date
0xsline
63f82e02b8 fix: normalize openai-codex gpt-5.4 transport overrides 2026-03-08 23:33:21 +00:00
87 changed files with 784 additions and 2496 deletions

View File

@@ -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:

View File

@@ -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"
}

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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}

View File

@@ -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;
}

View File

@@ -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 }

View File

@@ -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

View File

@@ -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,
});

View File

@@ -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;

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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(),
});

View File

@@ -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;
}

View File

@@ -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(),
});

View File

@@ -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;
}

View File

@@ -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 . .

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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}" \

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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,

View File

@@ -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,
});
}

View File

@@ -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",
});
});
});

View File

@@ -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);

View File

@@ -13,7 +13,6 @@ export type SubagentRunRecord = {
cleanup: "delete" | "keep";
label?: string;
model?: string;
workspaceDir?: string;
runTimeoutSeconds?: number;
spawnMode?: SpawnSubagentMode;
createdAt: number;

View File

@@ -650,7 +650,6 @@ export async function spawnSubagentDirect(
cleanup,
label: label || undefined,
model: resolvedModel,
workspaceDir: spawnedMetadata.workspaceDir,
runTimeoutSeconds,
expectsCompletionMessage,
spawnMode,

View File

@@ -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") {

View File

@@ -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" });

View File

@@ -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(),

View File

@@ -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();
});

View File

@@ -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);
});
});

View File

@@ -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 }> = [];

View File

@@ -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);
}

View File

@@ -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();
}
});
});

View File

@@ -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);
});
});

View File

@@ -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),
);
});
}

View File

@@ -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(() => {});
}
}
}

View File

@@ -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: {

View File

@@ -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,
});
}
});
}

View File

@@ -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");

View File

@@ -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

View File

@@ -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") {

View File

@@ -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(() => ({}));

View File

@@ -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);

View File

@@ -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[]> {

View File

@@ -1,6 +1,5 @@
export {
clearConfigCache,
ConfigRuntimeRefreshError,
clearRuntimeConfigSnapshot,
createConfigIO,
getRuntimeConfigSnapshot,
@@ -11,7 +10,6 @@ export {
readConfigFileSnapshot,
readConfigFileSnapshotForWrite,
resolveConfigSnapshotHash,
setRuntimeConfigSnapshotRefreshHandler,
setRuntimeConfigSnapshot,
writeConfigFile,
} from "./io.js";

View File

@@ -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();
}
});
});
});

View File

@@ -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).
}

View File

@@ -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);
});
});

View File

@@ -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");

View File

@@ -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"))
);
}

View File

@@ -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");
});
});

View File

@@ -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

View File

@@ -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;

View File

@@ -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";

View File

@@ -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;
},
};
}

View File

@@ -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: {

View File

@@ -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();
}

View File

@@ -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 {