Merge branch 'main' into vincentkoc-code/docker-cache-layer-fixes

This commit is contained in:
Vincent Koc 2026-03-08 16:57:14 -07:00 committed by GitHub
commit 7757f6ff71
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
78 changed files with 2611 additions and 783 deletions

View File

@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai
- Hooks/session-memory: keep `/new` and `/reset` memory artifacts in the bound agent workspace and align saved reset session keys with that workspace when stale main-agent keys leak into the hook path. (#39875) thanks @rbutera.
- Sessions/model switch: clear stale cached `contextTokens` when a session changes models so status and runtime paths recompute against the active model window. (#38044) thanks @yuweuii.
- ACP/session history: persist transcripts for successful ACP child runs, preserve exact transcript text, record ACP spawned-session lineage, and keep spawn-time transcript-path persistence best-effort so history storage failures do not block execution. (#40137) thanks @mbelinky.
- Agents/openai-codex: normalize `gpt-5.4` fallback transport back to `openai-codex-responses` on `chatgpt.com/backend-api` when config drifts to the generic OpenAI responses endpoint. (#38736) Thanks @0xsline.
- Browser/CDP: normalize loopback direct WebSocket CDP URLs back to HTTP(S) for `/json/*` tab operations so local `ws://` / `wss://` profiles can still list, focus, open, and close tabs after the new direct-WS support lands. (#31085) Thanks @shrey150.
- Browser/CDP: rewrite wildcard `ws://0.0.0.0` and `ws://[::]` debugger URLs from remote `/json/version` responses back to the external CDP host/port, fixing Browserless-style container endpoints. (#17760) Thanks @joeharouni.
- Browser/extension relay: wait briefly for a previously attached Chrome tab to reappear after transient relay drops before failing with `tab not found`, reducing noisy reconnect flakes. (#32461) Thanks @AaronWander.
@ -43,6 +44,8 @@ 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

@ -46,3 +46,19 @@ 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,4 +1,10 @@
import { buildRelayWsUrl, isRetryableReconnectError, reconnectDelayMs } from './background-utils.js'
import {
buildRelayWsUrl,
isLastRemainingTab,
isMissingTabError,
isRetryableReconnectError,
reconnectDelayMs,
} from './background-utils.js'
const DEFAULT_PORT = 18792
@ -41,6 +47,9 @@ 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 || ''
@ -49,6 +58,37 @@ 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
@ -108,15 +148,11 @@ async function rehydrateState() {
tabBySession.set(entry.sessionId, entry.tabId)
setBadge(entry.tabId, 'on')
}
// Phase 2: validate asynchronously, remove dead tabs.
// Retry once so transient busy/navigation states do not permanently drop
// a still-attached tab after a service worker restart.
for (const entry of entries) {
try {
await chrome.tabs.get(entry.tabId)
await chrome.debugger.sendCommand({ tabId: entry.tabId }, 'Runtime.evaluate', {
expression: '1',
returnByValue: true,
})
} catch {
const valid = await validateAttachedTab(entry.tabId)
if (!valid) {
tabs.delete(entry.tabId)
tabBySession.delete(entry.sessionId)
setBadge(entry.tabId, 'off')
@ -259,13 +295,10 @@ async function reannounceAttachedTabs() {
for (const [tabId, tab] of tabs.entries()) {
if (tab.state !== 'connected' || !tab.sessionId || !tab.targetId) continue
// Verify debugger is still attached.
try {
await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
expression: '1',
returnByValue: true,
})
} catch {
// Retry once here as well; reconnect races can briefly make an otherwise
// healthy tab look unavailable.
const valid = await validateAttachedTab(tabId)
if (!valid) {
tabs.delete(tabId)
if (tab.sessionId) tabBySession.delete(tab.sessionId)
setBadge(tabId, 'off')
@ -672,6 +705,11 @@ 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 }

299
docs/refactor/cluster.md Normal file
View File

@ -0,0 +1,299 @@
---
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,9 +1,8 @@
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),
@ -34,8 +33,8 @@ const bluebubblesAccountSchema = z
password: buildSecretInputSchema().optional(),
webhookPath: z.string().optional(),
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
allowFrom: z.array(allowFromEntry).optional(),
groupAllowFrom: z.array(allowFromEntry).optional(),
allowFrom: z.array(AllowFromEntrySchema).optional(),
groupAllowFrom: z.array(AllowFromEntrySchema).optional(),
groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(),
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
@ -60,8 +59,8 @@ const bluebubblesAccountSchema = z
}
});
export const BlueBubblesConfigSchema = bluebubblesAccountSchema.extend({
accounts: z.object({}).catchall(bluebubblesAccountSchema).optional(),
defaultAccount: z.string().optional(),
export const BlueBubblesConfigSchema = buildCatchallMultiAccountChannelSchema(
bluebubblesAccountSchema,
).extend({
actions: bluebubblesActionSchema,
});

View File

@ -1,31 +1,26 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import type { PluginRuntime } from "openclaw/plugin-sdk/bluebubbles";
let runtime: PluginRuntime | null = null;
const runtimeStore = createPluginRuntimeStore<PluginRuntime>("BlueBubbles runtime not initialized");
type LegacyRuntimeLogShape = { log?: (message: string) => void };
export function setBlueBubblesRuntime(next: PluginRuntime): void {
runtime = next;
}
export const setBlueBubblesRuntime = runtimeStore.setRuntime;
export function clearBlueBubblesRuntime(): void {
runtime = null;
runtimeStore.clearRuntime();
}
export function tryGetBlueBubblesRuntime(): PluginRuntime | null {
return runtime;
return runtimeStore.tryGetRuntime();
}
export function getBlueBubblesRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("BlueBubbles runtime not initialized");
}
return runtime;
return runtimeStore.getRuntime();
}
export function warnBlueBubbles(message: string): void {
const formatted = `[bluebubbles] ${message}`;
// Backward-compatible with tests/legacy injections that pass { log }.
const log = (runtime as unknown as LegacyRuntimeLogShape | null)?.log;
const log = (runtimeStore.tryGetRuntime() as unknown as LegacyRuntimeLogShape | null)?.log;
if (typeof log === "function") {
log(formatted);
return;

View File

@ -1,3 +1,4 @@
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk";
import {
buildAccountScopedDmSecurityPolicy,
collectOpenProviderGroupPolicyWarnings,
@ -13,7 +14,6 @@ import {
collectDiscordAuditChannelIds,
collectDiscordStatusIssues,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
discordOnboardingAdapter,
DiscordConfigSchema,
getChatChannelMeta,
@ -33,7 +33,6 @@ import {
resolveDefaultDiscordAccountId,
resolveDiscordGroupRequireMention,
resolveDiscordGroupToolPolicy,
setAccountEnabledInConfigSection,
type ChannelMessageActionAdapter,
type ChannelPlugin,
type ResolvedDiscordAccount,
@ -63,6 +62,15 @@ 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: {
@ -93,25 +101,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
reload: { configPrefixes: ["channels.discord"] },
configSchema: buildChannelConfigSchema(DiscordConfigSchema),
config: {
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"],
}),
...discordConfigBase,
isConfigured: (account) => Boolean(account.token?.trim()),
describeAccount: (account) => ({
accountId: account.accountId,

View File

@ -1,14 +1,6 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import type { PluginRuntime } from "openclaw/plugin-sdk/discord";
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;
}
const { setRuntime: setDiscordRuntime, getRuntime: getDiscordRuntime } =
createPluginRuntimeStore<PluginRuntime>("Discord runtime not initialized");
export { getDiscordRuntime, setDiscordRuntime };

View File

@ -1,14 +1,6 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import type { PluginRuntime } from "openclaw/plugin-sdk/feishu";
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;
}
const { setRuntime: setFeishuRuntime, getRuntime: getFeishuRuntime } =
createPluginRuntimeStore<PluginRuntime>("Feishu runtime not initialized");
export { getFeishuRuntime, setFeishuRuntime };

View File

@ -1,3 +1,4 @@
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk";
import {
buildAccountScopedDmSecurityPolicy,
buildOpenGroupPolicyConfigureRouteAllowlistWarning,
@ -11,7 +12,6 @@ import {
buildComputedAccountStatusSnapshot,
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
getChatChannelMeta,
listDirectoryGroupEntriesFromMapKeys,
listDirectoryUserEntriesFromAllowFrom,
@ -21,7 +21,6 @@ import {
PAIRING_APPROVED_MESSAGE,
resolveChannelMediaMaxBytes,
resolveGoogleChatGroupRequireMention,
setAccountEnabledInConfigSection,
type ChannelDock,
type ChannelMessageActionAdapter,
type ChannelPlugin,
@ -68,6 +67,23 @@ 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: {
@ -142,33 +158,7 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
reload: { configPrefixes: ["channels.googlechat"] },
configSchema: buildChannelConfigSchema(GoogleChatConfigSchema),
config: {
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",
],
}),
...googleChatConfigBase,
isConfigured: (account) => account.credentialSource !== "none",
describeAccount: (account) => ({
accountId: account.accountId,

View File

@ -1,14 +1,6 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import type { PluginRuntime } from "openclaw/plugin-sdk/googlechat";
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;
}
const { setRuntime: setGoogleChatRuntime, getRuntime: getGoogleChatRuntime } =
createPluginRuntimeStore<PluginRuntime>("Google Chat runtime not initialized");
export { getGoogleChatRuntime, setGoogleChatRuntime };

View File

@ -1,14 +1,6 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import type { PluginRuntime } from "openclaw/plugin-sdk/imessage";
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;
}
const { setRuntime: setIMessageRuntime, getRuntime: getIMessageRuntime } =
createPluginRuntimeStore<PluginRuntime>("iMessage runtime not initialized");
export { getIMessageRuntime, setIMessageRuntime };

View File

@ -1,14 +1,6 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import type { PluginRuntime } from "openclaw/plugin-sdk/irc";
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;
}
const { setRuntime: setIrcRuntime, getRuntime: getIrcRuntime } =
createPluginRuntimeStore<PluginRuntime>("IRC runtime not initialized");
export { getIrcRuntime, setIrcRuntime };

View File

@ -1,14 +1,6 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import type { PluginRuntime } from "openclaw/plugin-sdk/line";
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;
}
const { setRuntime: setLineRuntime, getRuntime: getLineRuntime } =
createPluginRuntimeStore<PluginRuntime>("LINE runtime not initialized - plugin not registered");
export { getLineRuntime, setLineRuntime };

View File

@ -1,14 +1,6 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import type { PluginRuntime } from "openclaw/plugin-sdk/matrix";
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;
}
const { setRuntime: setMatrixRuntime, getRuntime: getMatrixRuntime } =
createPluginRuntimeStore<PluginRuntime>("Matrix runtime not initialized");
export { getMatrixRuntime, setMatrixRuntime };

View File

@ -1,14 +1,6 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import type { PluginRuntime } from "openclaw/plugin-sdk/mattermost";
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;
}
const { setRuntime: setMattermostRuntime, getRuntime: getMattermostRuntime } =
createPluginRuntimeStore<PluginRuntime>("Mattermost runtime not initialized");
export { getMattermostRuntime, setMattermostRuntime };

View File

@ -1,14 +1,6 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import type { PluginRuntime } from "openclaw/plugin-sdk/msteams";
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;
}
const { setRuntime: setMSTeamsRuntime, getRuntime: getMSTeamsRuntime } =
createPluginRuntimeStore<PluginRuntime>("MSTeams runtime not initialized");
export { getMSTeamsRuntime, setMSTeamsRuntime };

View File

@ -1,14 +1,6 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import type { PluginRuntime } from "openclaw/plugin-sdk/nextcloud-talk";
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;
}
const { setRuntime: setNextcloudTalkRuntime, getRuntime: getNextcloudTalkRuntime } =
createPluginRuntimeStore<PluginRuntime>("Nextcloud Talk runtime not initialized");
export { getNextcloudTalkRuntime, setNextcloudTalkRuntime };

View File

@ -1,14 +1,6 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import type { PluginRuntime } from "openclaw/plugin-sdk/nostr";
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;
}
const { setRuntime: setNostrRuntime, getRuntime: getNostrRuntime } =
createPluginRuntimeStore<PluginRuntime>("Nostr runtime not initialized");
export { getNostrRuntime, setNostrRuntime };

View File

@ -1,14 +1,6 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import type { PluginRuntime } from "openclaw/plugin-sdk/signal";
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;
}
const { setRuntime: setSignalRuntime, getRuntime: getSignalRuntime } =
createPluginRuntimeStore<PluginRuntime>("Signal runtime not initialized");
export { getSignalRuntime, setSignalRuntime };

View File

@ -1,3 +1,4 @@
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk";
import {
buildAccountScopedDmSecurityPolicy,
collectOpenProviderGroupPolicyWarnings,
@ -10,7 +11,6 @@ import {
buildComputedAccountStatusSnapshot,
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
extractSlackToolSend,
getChatChannelMeta,
handleSlackMessageAction,
@ -32,7 +32,6 @@ import {
resolveSlackGroupRequireMention,
resolveSlackGroupToolPolicy,
buildSlackThreadingToolContext,
setAccountEnabledInConfigSection,
slackOnboardingAdapter,
SlackConfigSchema,
type ChannelPlugin,
@ -96,6 +95,15 @@ 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: {
@ -144,25 +152,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
reload: { configPrefixes: ["channels.slack"] },
configSchema: buildChannelConfigSchema(SlackConfigSchema),
config: {
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"],
}),
...slackConfigBase,
isConfigured: (account) => isSlackAccountConfigured(account),
describeAccount: (account) => ({
accountId: account.accountId,

View File

@ -1,14 +1,6 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import type { PluginRuntime } from "openclaw/plugin-sdk/slack";
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;
}
const { setRuntime: setSlackRuntime, getRuntime: getSlackRuntime } =
createPluginRuntimeStore<PluginRuntime>("Slack runtime not initialized");
export { getSlackRuntime, setSlackRuntime };

View File

@ -1,20 +1,8 @@
/**
* Plugin runtime singleton.
* Stores the PluginRuntime from api.runtime (set during register()).
* Used by channel.ts to access dispatch functions.
*/
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import type { PluginRuntime } from "openclaw/plugin-sdk/synology-chat";
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;
}
const { setRuntime: setSynologyRuntime, getRuntime: getSynologyRuntime } =
createPluginRuntimeStore<PluginRuntime>(
"Synology Chat runtime not initialized - plugin not registered",
);
export { getSynologyRuntime, setSynologyRuntime };

View File

@ -1,3 +1,4 @@
import { createScopedChannelConfigBase } from "openclaw/plugin-sdk";
import {
collectAllowlistProviderGroupPolicyWarnings,
buildAccountScopedDmSecurityPolicy,
@ -12,7 +13,6 @@ import {
clearAccountEntryFields,
collectTelegramStatusIssues,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
getChatChannelMeta,
inspectTelegramAccount,
listTelegramAccountIds,
@ -31,7 +31,6 @@ import {
resolveTelegramAccount,
resolveTelegramGroupRequireMention,
resolveTelegramGroupToolPolicy,
setAccountEnabledInConfigSection,
telegramOnboardingAdapter,
TelegramConfigSchema,
type ChannelMessageActionAdapter,
@ -100,6 +99,15 @@ 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: {
@ -136,25 +144,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
reload: { configPrefixes: ["channels.telegram"] },
configSchema: buildChannelConfigSchema(TelegramConfigSchema),
config: {
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"],
}),
...telegramConfigBase,
isConfigured: (account, cfg) => {
if (!account.token?.trim()) {
return false;

View File

@ -1,14 +1,6 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import type { PluginRuntime } from "openclaw/plugin-sdk/telegram";
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;
}
const { setRuntime: setTelegramRuntime, getRuntime: getTelegramRuntime } =
createPluginRuntimeStore<PluginRuntime>("Telegram runtime not initialized");
export { getTelegramRuntime, setTelegramRuntime };

View File

@ -1,14 +1,6 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import type { PluginRuntime } from "openclaw/plugin-sdk/tlon";
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;
}
const { setRuntime: setTlonRuntime, getRuntime: getTlonRuntime } =
createPluginRuntimeStore<PluginRuntime>("Tlon runtime not initialized");
export { getTlonRuntime, setTlonRuntime };

View File

@ -1,14 +1,6 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import type { PluginRuntime } from "openclaw/plugin-sdk/twitch";
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;
}
const { setRuntime: setTwitchRuntime, getRuntime: getTwitchRuntime } =
createPluginRuntimeStore<PluginRuntime>("Twitch runtime not initialized");
export { getTwitchRuntime, setTwitchRuntime };

View File

@ -1,14 +1,6 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import type { PluginRuntime } from "openclaw/plugin-sdk/whatsapp";
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;
}
const { setRuntime: setWhatsAppRuntime, getRuntime: getWhatsAppRuntime } =
createPluginRuntimeStore<PluginRuntime>("WhatsApp runtime not initialized");
export { getWhatsAppRuntime, setWhatsAppRuntime };

View File

@ -1,9 +1,8 @@
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(),
@ -14,15 +13,12 @@ const zaloAccountSchema = z.object({
webhookSecret: buildSecretInputSchema().optional(),
webhookPath: z.string().optional(),
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
allowFrom: z.array(allowFromEntry).optional(),
allowFrom: z.array(AllowFromEntrySchema).optional(),
groupPolicy: z.enum(["disabled", "allowlist", "open"]).optional(),
groupAllowFrom: z.array(allowFromEntry).optional(),
groupAllowFrom: z.array(AllowFromEntrySchema).optional(),
mediaMaxMb: z.number().optional(),
proxy: z.string().optional(),
responsePrefix: z.string().optional(),
});
export const ZaloConfigSchema = zaloAccountSchema.extend({
accounts: z.object({}).catchall(zaloAccountSchema).optional(),
defaultAccount: z.string().optional(),
});
export const ZaloConfigSchema = buildCatchallMultiAccountChannelSchema(zaloAccountSchema);

View File

@ -1,14 +1,6 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import type { PluginRuntime } from "openclaw/plugin-sdk/zalo";
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;
}
const { setRuntime: setZaloRuntime, getRuntime: getZaloRuntime } =
createPluginRuntimeStore<PluginRuntime>("Zalo runtime not initialized");
export { getZaloRuntime, setZaloRuntime };

View File

@ -1,8 +1,7 @@
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(),
@ -16,16 +15,13 @@ const zalouserAccountSchema = z.object({
markdown: MarkdownConfigSchema,
profile: z.string().optional(),
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
allowFrom: z.array(allowFromEntry).optional(),
allowFrom: z.array(AllowFromEntrySchema).optional(),
historyLimit: z.number().int().min(0).optional(),
groupAllowFrom: z.array(allowFromEntry).optional(),
groupAllowFrom: z.array(AllowFromEntrySchema).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 = zalouserAccountSchema.extend({
accounts: z.object({}).catchall(zalouserAccountSchema).optional(),
defaultAccount: z.string().optional(),
});
export const ZalouserConfigSchema = buildCatchallMultiAccountChannelSchema(zalouserAccountSchema);

View File

@ -1,14 +1,6 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk";
import type { PluginRuntime } from "openclaw/plugin-sdk/zalouser";
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;
}
const { setRuntime: setZalouserRuntime, getRuntime: getZalouserRuntime } =
createPluginRuntimeStore<PluginRuntime>("Zalouser runtime not initialized");
export { getZalouserRuntime, setZalouserRuntime };

View File

@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const {
hookRunner,
ensureRuntimePluginsLoaded,
resolveModelMock,
sessionCompactImpl,
triggerInternalHook,
@ -12,6 +13,7 @@ const {
runBeforeCompaction: vi.fn(),
runAfterCompaction: vi.fn(),
},
ensureRuntimePluginsLoaded: vi.fn(),
resolveModelMock: vi.fn(() => ({
model: { provider: "openai", api: "responses", id: "fake", input: [] },
error: null,
@ -32,6 +34,10 @@ 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",
@ -254,6 +260,7 @@ const sessionHook = (action: string) =>
describe("compactEmbeddedPiSessionDirect hooks", () => {
beforeEach(() => {
ensureRuntimePluginsLoaded.mockReset();
triggerInternalHook.mockClear();
hookRunner.hasHooks.mockReset();
hookRunner.runBeforeCompaction.mockReset();
@ -279,6 +286,19 @@ 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,6 +50,7 @@ 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";
@ -269,6 +270,10 @@ 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
@ -910,6 +915,10 @@ 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

@ -664,6 +664,60 @@ describe("resolveModel", () => {
});
});
it("normalizes openai-codex gpt-5.4 overrides away from /v1/responses", () => {
mockOpenAICodexTemplateModel();
const cfg: OpenClawConfig = {
models: {
providers: {
"openai-codex": {
baseUrl: "https://api.openai.com/v1",
api: "openai-responses",
},
},
},
} as unknown as OpenClawConfig;
expectResolvedForwardCompatFallback({
provider: "openai-codex",
id: "gpt-5.4",
cfg,
expectedModel: {
api: "openai-codex-responses",
baseUrl: "https://chatgpt.com/backend-api",
id: "gpt-5.4",
provider: "openai-codex",
},
});
});
it("does not rewrite openai baseUrl when openai-codex api stays non-codex", () => {
mockOpenAICodexTemplateModel();
const cfg: OpenClawConfig = {
models: {
providers: {
"openai-codex": {
baseUrl: "https://api.openai.com/v1",
api: "openai-completions",
},
},
},
} as unknown as OpenClawConfig;
expectResolvedForwardCompatFallback({
provider: "openai-codex",
id: "gpt-5.4",
cfg,
expectedModel: {
api: "openai-completions",
baseUrl: "https://api.openai.com/v1",
id: "gpt-5.4",
provider: "openai-codex",
},
});
});
it("includes auth hint for unknown ollama models (#17328)", () => {
// resetMockDiscoverModels() in beforeEach already sets find → null
const result = resolveModel("ollama", "gemma3:4b", "/tmp/agent");

View File

@ -23,6 +23,8 @@ type InlineProviderConfig = {
headers?: unknown;
};
const OPENAI_CODEX_BASE_URL = "https://chatgpt.com/backend-api";
function sanitizeModelHeaders(
headers: unknown,
opts?: { stripSecretRefMarkers?: boolean },
@ -43,6 +45,60 @@ function sanitizeModelHeaders(
return Object.keys(next).length > 0 ? next : undefined;
}
function isOpenAIApiBaseUrl(baseUrl?: string): boolean {
const trimmed = baseUrl?.trim();
if (!trimmed) {
return false;
}
return /^https?:\/\/api\.openai\.com(?:\/v1)?\/?$/i.test(trimmed);
}
function isOpenAICodexBaseUrl(baseUrl?: string): boolean {
const trimmed = baseUrl?.trim();
if (!trimmed) {
return false;
}
return /^https?:\/\/chatgpt\.com\/backend-api\/?$/i.test(trimmed);
}
function normalizeOpenAICodexTransport(params: {
provider: string;
model: Model<Api>;
}): Model<Api> {
if (normalizeProviderId(params.provider) !== "openai-codex") {
return params.model;
}
const useCodexTransport =
!params.model.baseUrl ||
isOpenAIApiBaseUrl(params.model.baseUrl) ||
isOpenAICodexBaseUrl(params.model.baseUrl);
const nextApi =
useCodexTransport && params.model.api === "openai-responses"
? ("openai-codex-responses" as const)
: params.model.api;
const nextBaseUrl =
nextApi === "openai-codex-responses" &&
(!params.model.baseUrl || isOpenAIApiBaseUrl(params.model.baseUrl))
? OPENAI_CODEX_BASE_URL
: params.model.baseUrl;
if (nextApi === params.model.api && nextBaseUrl === params.model.baseUrl) {
return params.model;
}
return {
...params.model,
api: nextApi,
baseUrl: nextBaseUrl,
} as Model<Api>;
}
function normalizeResolvedModel(params: { provider: string; model: Model<Api> }): Model<Api> {
return normalizeModelCompat(normalizeOpenAICodexTransport(params));
}
export { buildModelAliasLines };
function resolveConfiguredProviderConfig(
@ -145,13 +201,14 @@ export function resolveModelWithRegistry(params: {
const model = modelRegistry.find(provider, modelId) as Model<Api> | null;
if (model) {
return normalizeModelCompat(
applyConfiguredProviderOverrides({
return normalizeResolvedModel({
provider,
model: applyConfiguredProviderOverrides({
discoveredModel: model,
providerConfig,
modelId,
}),
);
});
}
const providers = cfg?.models?.providers ?? {};
@ -161,64 +218,71 @@ export function resolveModelWithRegistry(params: {
(entry) => normalizeProviderId(entry.provider) === normalizedProvider && entry.id === modelId,
);
if (inlineMatch?.api) {
return normalizeModelCompat(inlineMatch as Model<Api>);
return normalizeResolvedModel({ provider, model: inlineMatch as Model<Api> });
}
// Forward-compat fallbacks must be checked BEFORE the generic providerCfg fallback.
// Otherwise, configured providers can default to a generic API and break specific transports.
const forwardCompat = resolveForwardCompatModel(provider, modelId, modelRegistry);
if (forwardCompat) {
return normalizeModelCompat(
applyConfiguredProviderOverrides({
return normalizeResolvedModel({
provider,
model: applyConfiguredProviderOverrides({
discoveredModel: forwardCompat,
providerConfig,
modelId,
}),
);
});
}
// OpenRouter is a pass-through proxy - any model ID available on OpenRouter
// should work without being pre-registered in the local catalog.
if (normalizedProvider === "openrouter") {
return normalizeModelCompat({
id: modelId,
name: modelId,
api: "openai-completions",
return normalizeResolvedModel({
provider,
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: DEFAULT_CONTEXT_TOKENS,
// Align with OPENROUTER_DEFAULT_MAX_TOKENS in models-config.providers.ts
maxTokens: 8192,
} as Model<Api>);
model: {
id: modelId,
name: modelId,
api: "openai-completions",
provider,
baseUrl: "https://openrouter.ai/api/v1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: DEFAULT_CONTEXT_TOKENS,
// Align with OPENROUTER_DEFAULT_MAX_TOKENS in models-config.providers.ts
maxTokens: 8192,
} as Model<Api>,
});
}
const configuredModel = providerConfig?.models?.find((candidate) => candidate.id === modelId);
const providerHeaders = sanitizeModelHeaders(providerConfig?.headers);
const modelHeaders = sanitizeModelHeaders(configuredModel?.headers);
if (providerConfig || modelId.startsWith("mock-")) {
return normalizeModelCompat({
id: modelId,
name: modelId,
api: providerConfig?.api ?? "openai-responses",
return normalizeResolvedModel({
provider,
baseUrl: providerConfig?.baseUrl,
reasoning: configuredModel?.reasoning ?? false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow:
configuredModel?.contextWindow ??
providerConfig?.models?.[0]?.contextWindow ??
DEFAULT_CONTEXT_TOKENS,
maxTokens:
configuredModel?.maxTokens ??
providerConfig?.models?.[0]?.maxTokens ??
DEFAULT_CONTEXT_TOKENS,
headers:
providerHeaders || modelHeaders ? { ...providerHeaders, ...modelHeaders } : undefined,
} as Model<Api>);
model: {
id: modelId,
name: modelId,
api: providerConfig?.api ?? "openai-responses",
provider,
baseUrl: providerConfig?.baseUrl,
reasoning: configuredModel?.reasoning ?? false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow:
configuredModel?.contextWindow ??
providerConfig?.models?.[0]?.contextWindow ??
DEFAULT_CONTEXT_TOKENS,
maxTokens:
configuredModel?.maxTokens ??
providerConfig?.models?.[0]?.maxTokens ??
DEFAULT_CONTEXT_TOKENS,
headers:
providerHeaders || modelHeaders ? { ...providerHeaders, ...modelHeaders } : undefined,
} as Model<Api>,
});
}
return undefined;

View File

@ -54,6 +54,7 @@ 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";
@ -287,6 +288,10 @@ 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,5 +1,14 @@
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";
@ -10,6 +19,32 @@ 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

@ -0,0 +1,18 @@
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

@ -0,0 +1,91 @@
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,6 +16,7 @@ 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,
@ -313,10 +314,16 @@ 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(loadConfig());
const engine = await resolveContextEngine(cfg);
if (!engine.onSubagentEnded) {
return;
}
@ -714,6 +721,7 @@ async function sweepSubagentRuns() {
void notifyContextEngineSubagentEnded({
childSessionKey: entry.childSessionKey,
reason: "swept",
workspaceDir: entry.workspaceDir,
});
subagentRuns.delete(runId);
mutated = true;
@ -963,6 +971,7 @@ function completeCleanupBookkeeping(params: {
void notifyContextEngineSubagentEnded({
childSessionKey: params.entry.childSessionKey,
reason: "deleted",
workspaceDir: params.entry.workspaceDir,
});
subagentRuns.delete(params.runId);
persistSubagentRuns();
@ -972,6 +981,7 @@ function completeCleanupBookkeeping(params: {
void notifyContextEngineSubagentEnded({
childSessionKey: params.entry.childSessionKey,
reason: "completed",
workspaceDir: params.entry.workspaceDir,
});
params.entry.cleanupCompletedAt = params.completedAt;
persistSubagentRuns();
@ -1143,6 +1153,7 @@ export function registerSubagentRun(params: {
cleanup: "delete" | "keep";
label?: string;
model?: string;
workspaceDir?: string;
runTimeoutSeconds?: number;
expectsCompletionMessage?: boolean;
spawnMode?: "run" | "session";
@ -1171,6 +1182,7 @@ export function registerSubagentRun(params: {
spawnMode,
label: params.label,
model: params.model,
workspaceDir: params.workspaceDir,
runTimeoutSeconds,
createdAt: now,
startedAt: now,
@ -1285,6 +1297,7 @@ export function releaseSubagentRun(runId: string) {
void notifyContextEngineSubagentEnded({
childSessionKey: entry.childSessionKey,
reason: "released",
workspaceDir: entry.workspaceDir,
});
}
const didDelete = subagentRuns.delete(runId);

View File

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

View File

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

View File

@ -112,16 +112,19 @@ export async function executeSnapshotAction(params: {
}): Promise<AgentToolResult<unknown>> {
const { input, baseUrl, profile, proxyRequest } = params;
const snapshotDefaults = loadConfig().browser?.snapshotDefaults;
const format =
input.snapshotFormat === "ai" || input.snapshotFormat === "aria" ? input.snapshotFormat : "ai";
const mode =
const format: "ai" | "aria" | undefined =
input.snapshotFormat === "ai" || input.snapshotFormat === "aria"
? input.snapshotFormat
: undefined;
const mode: "efficient" | undefined =
input.mode === "efficient"
? "efficient"
: format === "ai" && snapshotDefaults?.mode === "efficient"
: format !== "aria" && snapshotDefaults?.mode === "efficient"
? "efficient"
: undefined;
const labels = typeof input.labels === "boolean" ? input.labels : undefined;
const refs = input.refs === "aria" || input.refs === "role" ? input.refs : undefined;
const refs: "aria" | "role" | undefined =
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 =
@ -130,6 +133,12 @@ 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
@ -137,46 +146,32 @@ export async function executeSnapshotAction(params: {
: mode === "efficient"
? undefined
: DEFAULT_AI_SNAPSHOT_MAX_CHARS
: 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;
: hasMaxChars
? maxChars
: undefined;
const snapshotQuery = {
...(format ? { format } : {}),
targetId,
limit,
...(typeof resolvedMaxChars === "number" ? { maxChars: resolvedMaxChars } : {}),
refs,
interactive,
compact,
depth,
selector,
frame,
labels,
mode,
};
const snapshot = proxyRequest
? ((await proxyRequest({
method: "GET",
path: "/snapshot",
profile,
query: {
format,
targetId,
limit,
...(typeof resolvedMaxChars === "number" ? { maxChars: resolvedMaxChars } : {}),
refs,
interactive,
compact,
depth,
selector,
frame,
labels,
mode,
},
query: snapshotQuery,
})) as Awaited<ReturnType<typeof browserSnapshot>>)
: await browserSnapshot(baseUrl, {
format,
targetId,
limit,
...(typeof resolvedMaxChars === "number" ? { maxChars: resolvedMaxChars } : {}),
refs,
interactive,
compact,
depth,
selector,
frame,
labels,
mode,
...snapshotQuery,
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,6 +243,23 @@ 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();
@ -250,15 +267,44 @@ describe("browser tool snapshot maxChars", () => {
expect(gatewayMocks.callGatewayTool).toHaveBeenCalledWith(
"node.invoke",
{ timeoutMs: 20000 },
{ timeoutMs: 25000 },
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,6 +115,7 @@ type BrowserProxyResult = {
};
const DEFAULT_BROWSER_PROXY_TIMEOUT_MS = 20_000;
const BROWSER_PROXY_GATEWAY_TIMEOUT_SLACK_MS = 5_000;
type BrowserNodeTarget = {
nodeId: string;
@ -206,10 +207,11 @@ async function callBrowserProxy(params: {
timeoutMs?: number;
profile?: string;
}): Promise<BrowserProxyResult> {
const gatewayTimeoutMs =
const proxyTimeoutMs =
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 },
@ -221,7 +223,7 @@ async function callBrowserProxy(params: {
path: params.path,
query: params.query,
body: params.body,
timeoutMs: params.timeoutMs,
timeoutMs: proxyTimeoutMs,
profile: params.profile,
},
idempotencyKey: crypto.randomUUID(),

View File

@ -772,7 +772,25 @@ describe("web_search external content wrapping", () => {
expect(mockFetch).not.toHaveBeenCalled();
});
it("rejects date_after/date_before in Brave llm-context mode", async () => {
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) => {
vi.stubEnv("BRAVE_API_KEY", "test-key");
const mockFetch = installBraveLlmContextFetch({
title: "unused",
@ -795,45 +813,9 @@ describe("web_search external content wrapping", () => {
},
sandboxed: true,
});
const result = await tool?.execute?.("call-1", {
query: "test",
date_after: "2025-01-01",
date_before: "2025-01-31",
});
const result = await tool?.execute?.("call-1", input);
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(result?.details).toMatchObject({ error: expectedError });
expect(mockFetch).not.toHaveBeenCalled();
});

View File

@ -4,6 +4,11 @@ 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,
@ -26,8 +31,14 @@ async function loadBackgroundUtils(): Promise<BackgroundUtilsModule> {
}
}
const { buildRelayWsUrl, deriveRelayToken, isRetryableReconnectError, reconnectDelayMs } =
await loadBackgroundUtils();
const {
buildRelayWsUrl,
deriveRelayToken,
isLastRemainingTab,
isMissingTabError,
isRetryableReconnectError,
reconnectDelayMs,
} = await loadBackgroundUtils();
describe("chrome extension background utils", () => {
it("derives relay token as HMAC-SHA256 of gateway token and port", async () => {
@ -107,4 +118,16 @@ 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,6 +101,21 @@ 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,7 +292,9 @@ export async function browserSnapshot(
},
): Promise<SnapshotResult> {
const q = new URLSearchParams();
q.set("format", opts.format);
if (opts.format) {
q.set("format", opts.format);
}
if (opts.targetId) {
q.set("targetId", opts.targetId);
}

View File

@ -115,4 +115,67 @@ 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

@ -0,0 +1,94 @@
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

@ -0,0 +1,81 @@
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,6 +24,7 @@ import {
assertBrowserNavigationResultAllowed,
withBrowserNavigationPolicy,
} from "./navigation-guard.js";
import { isExtensionRelayCdpEndpoint, withPageScopedCdpClient } from "./pw-session.page-cdp.js";
export type BrowserConsoleMessage = {
type: string;
@ -398,14 +399,70 @@ 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 {
@ -418,46 +475,16 @@ 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 {
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];
}
}
}
}
return await findPageByTargetIdViaTargetList(pages, targetId, cdpUrl);
} 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;
}
@ -806,14 +833,18 @@ export async function focusPageByTargetIdViaPlaywright(opts: {
try {
await page.bringToFront();
} catch (err) {
const session = await page.context().newCDPSession(page);
try {
await session.send("Page.bringToFront");
await withPageScopedCdpClient({
cdpUrl: opts.cdpUrl,
page,
targetId: opts.targetId,
fn: async (send) => {
await send("Page.bringToFront");
},
});
return;
} catch {
throw err;
} finally {
await session.detach().catch(() => {});
}
}
}

View File

@ -19,6 +19,7 @@ import {
storeRoleRefsForTarget,
type WithSnapshotForAI,
} from "./pw-session.js";
import { withPageScopedCdpClient } from "./pw-session.page-cdp.js";
export async function snapshotAriaViaPlaywright(opts: {
cdpUrl: string;
@ -31,17 +32,21 @@ export async function snapshotAriaViaPlaywright(opts: {
targetId: opts.targetId,
});
ensurePageState(page);
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(() => {});
}
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) };
}
export async function snapshotAiViaPlaywright(opts: {

View File

@ -1,15 +1,6 @@
import type { CDPSession, Page } from "playwright-core";
import { devices as playwrightDevices } from "playwright-core";
import { ensurePageState, getPageForTargetId } from "./pw-session.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(() => {});
}
}
import { withPageScopedCdpClient } from "./pw-session.page-cdp.js";
export async function setOfflineViaPlaywright(opts: {
cdpUrl: string;
@ -112,15 +103,20 @@ export async function setLocaleViaPlaywright(opts: {
if (!locale) {
throw new Error("locale is required");
}
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;
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;
}
throw err;
}
},
});
}
@ -135,19 +131,24 @@ export async function setTimezoneViaPlaywright(opts: {
if (!timezoneId) {
throw new Error("timezoneId is required");
}
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;
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;
}
if (msg.includes("Invalid timezone")) {
throw new Error(`Invalid timezone ID: ${timezoneId}`, { cause: err });
}
throw err;
}
},
});
}
@ -183,27 +184,32 @@ export async function setDeviceViaPlaywright(opts: {
});
}
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,
});
}
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,
});
}
},
});
}

View File

@ -5,17 +5,27 @@ 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", () => ({
@ -76,6 +86,8 @@ describe("stopKnownBrowserProfiles", () => {
beforeEach(() => {
createBrowserRouteContextMock.mockClear();
listKnownProfileNamesMock.mockClear();
stopOpenClawChromeMock.mockClear();
stopChromeExtensionRelayServerMock.mockClear();
});
it("stops all known profiles and ignores per-profile failures", async () => {
@ -104,6 +116,53 @@ 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,6 +1,10 @@
import { stopOpenClawChrome } from "./chrome.js";
import type { ResolvedBrowserConfig } from "./config.js";
import { resolveProfile } from "./config.js";
import { ensureChromeExtensionRelayServer } from "./extension-relay.js";
import {
ensureChromeExtensionRelayServer,
stopChromeExtensionRelayServer,
} from "./extension-relay.js";
import {
type BrowserServerState,
createBrowserRouteContext,
@ -40,6 +44,18 @@ 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,10 +1,25 @@
import type { ZodTypeAny } from "zod";
import { z, 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,16 +36,17 @@ 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,6 +5,7 @@ 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";
@ -42,17 +43,6 @@ 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/)
@ -68,31 +58,6 @@ 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 {
@ -135,7 +100,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);
return args != null && isGatewayArgv(args, { allowGatewayBinary: true });
});
}
@ -147,7 +112,7 @@ function resolveGatewayPortFallback(): Promise<number> {
function signalGatewayPid(pid: number, signal: "SIGTERM" | "SIGUSR1") {
const args = readGatewayProcessArgsSync(pid);
if (!args || !isGatewayArgv(args)) {
if (!args || !isGatewayArgv(args, { allowGatewayBinary: true })) {
throw new Error(`refusing to signal non-gateway process pid ${pid}`);
}
process.kill(pid, signal);

View File

@ -242,6 +242,22 @@ 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 = [
@ -257,33 +273,13 @@ export function renderRestartDiagnostics(snapshot: GatewayRestartSnapshot): stri
lines.push(`Service runtime: ${runtimeSummary}`);
}
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("; ")}`);
}
lines.push(...renderPortUsageDiagnostics(snapshot));
return lines;
}
export function renderGatewayPortHealthDiagnostics(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;
return renderPortUsageDiagnostics(snapshot);
}
export async function terminateStaleGatewayPids(pids: number[]): Promise<number[]> {

View File

@ -354,8 +354,8 @@ describe("models list/status", () => {
await modelsListCommand({ all: true, json: true }, runtime);
expect(ensureOpenClawModelsJson).toHaveBeenCalledTimes(1);
expect(ensureOpenClawModelsJson).toHaveBeenCalledWith(resolvedConfig);
expect(ensureOpenClawModelsJson).toHaveBeenCalled();
expect(ensureOpenClawModelsJson.mock.calls[0]?.[0]).toEqual(resolvedConfig);
});
it("toModelRow does not crash without cfg/authStore when availability is undefined", async () => {

View File

@ -38,6 +38,7 @@ const mocks = vi.hoisted(() => {
loadModelRegistry: vi
.fn()
.mockResolvedValue({ models: [], availableKeys: new Set(), registry: {} }),
loadModelCatalog: vi.fn().mockResolvedValue([]),
resolveConfiguredEntries: vi.fn().mockReturnValue({
entries: [
{
@ -66,6 +67,8 @@ const mocks = vi.hoisted(() => {
vi.mock("../../config/config.js", () => ({
loadConfig: mocks.loadConfig,
getRuntimeConfigSnapshot: vi.fn().mockReturnValue(null),
getRuntimeConfigSourceSnapshot: vi.fn().mockReturnValue(null),
}));
vi.mock("../../agents/auth-profiles.js", async (importOriginal) => {
@ -77,6 +80,10 @@ vi.mock("../../agents/auth-profiles.js", async (importOriginal) => {
};
});
vi.mock("../../agents/model-catalog.js", () => ({
loadModelCatalog: mocks.loadModelCatalog,
}));
vi.mock("./list.registry.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./list.registry.js")>();
return {
@ -177,25 +184,163 @@ describe("modelsListCommand forward-compat", () => {
availableKeys: new Set(),
registry: {},
});
mocks.listProfilesForProvider.mockImplementationOnce((_: unknown, provider: string) =>
mocks.listProfilesForProvider.mockImplementation((_: unknown, provider: string) =>
provider === "openai-codex" ? ([{ id: "profile-1" }] as Array<Record<string, unknown>>) : [],
);
const runtime = { log: vi.fn(), error: vi.fn() };
await modelsListCommand({ json: true }, runtime as never);
try {
await modelsListCommand({ json: true }, runtime as never);
expect(mocks.printModelTable).toHaveBeenCalled();
const rows = mocks.printModelTable.mock.calls.at(-1)?.[0] as Array<{
key: string;
available: boolean;
}>;
expect(rows).toContainEqual(
expect.objectContaining({
key: "openai-codex/gpt-5.4",
available: true,
}),
);
} finally {
mocks.listProfilesForProvider.mockReturnValue([]);
}
});
it("includes synthetic codex gpt-5.4 in --all output when catalog supports it", async () => {
mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] });
mocks.loadModelRegistry.mockResolvedValueOnce({
models: [
{
provider: "openai-codex",
id: "gpt-5.3-codex",
name: "GPT-5.3 Codex",
api: "openai-codex-responses",
baseUrl: "https://chatgpt.com/backend-api",
input: ["text"],
contextWindow: 272000,
maxTokens: 128000,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
},
],
availableKeys: new Set(["openai-codex/gpt-5.3-codex"]),
registry: {},
});
mocks.loadModelCatalog.mockResolvedValueOnce([
{
provider: "openai-codex",
id: "gpt-5.3-codex",
name: "GPT-5.3 Codex",
input: ["text"],
contextWindow: 272000,
},
{
provider: "openai-codex",
id: "gpt-5.4",
name: "GPT-5.4",
input: ["text"],
contextWindow: 272000,
},
]);
mocks.listProfilesForProvider.mockImplementation((_: unknown, provider: string) =>
provider === "openai-codex" ? ([{ id: "profile-1" }] as Array<Record<string, unknown>>) : [],
);
mocks.resolveModelWithRegistry.mockImplementation(
({ provider, modelId }: { provider: string; modelId: string }) => {
if (provider !== "openai-codex") {
return undefined;
}
if (modelId === "gpt-5.3-codex") {
return {
provider: "openai-codex",
id: "gpt-5.3-codex",
name: "GPT-5.3 Codex",
api: "openai-codex-responses",
baseUrl: "https://chatgpt.com/backend-api",
input: ["text"],
contextWindow: 272000,
maxTokens: 128000,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
};
}
if (modelId === "gpt-5.4") {
return {
provider: "openai-codex",
id: "gpt-5.4",
name: "GPT-5.4",
api: "openai-codex-responses",
baseUrl: "https://chatgpt.com/backend-api",
input: ["text"],
contextWindow: 272000,
maxTokens: 128000,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
};
}
return undefined;
},
);
const runtime = { log: vi.fn(), error: vi.fn() };
try {
await modelsListCommand(
{ all: true, provider: "openai-codex", json: true },
runtime as never,
);
expect(mocks.printModelTable).toHaveBeenCalled();
const rows = mocks.printModelTable.mock.calls.at(-1)?.[0] as Array<{
key: string;
available: boolean;
}>;
expect(rows).toEqual([
expect.objectContaining({
key: "openai-codex/gpt-5.3-codex",
}),
expect.objectContaining({
key: "openai-codex/gpt-5.4",
available: true,
}),
]);
} finally {
mocks.listProfilesForProvider.mockReturnValue([]);
}
});
it("keeps discovered rows in --all output when catalog lookup is empty", async () => {
mocks.resolveConfiguredEntries.mockReturnValueOnce({ entries: [] });
mocks.loadModelRegistry.mockResolvedValueOnce({
models: [
{
provider: "openai-codex",
id: "gpt-5.3-codex",
name: "GPT-5.3 Codex",
api: "openai-codex-responses",
baseUrl: "https://chatgpt.com/backend-api",
input: ["text"],
contextWindow: 272000,
maxTokens: 128000,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
},
],
availableKeys: new Set(["openai-codex/gpt-5.3-codex"]),
registry: {},
});
mocks.loadModelCatalog.mockResolvedValueOnce([]);
const runtime = { log: vi.fn(), error: vi.fn() };
await modelsListCommand({ all: true, provider: "openai-codex", json: true }, runtime as never);
expect(mocks.printModelTable).toHaveBeenCalled();
const rows = mocks.printModelTable.mock.calls.at(-1)?.[0] as Array<{
key: string;
available: boolean;
}>;
const rows = mocks.printModelTable.mock.calls.at(-1)?.[0] as Array<{ key: string }>;
expect(rows).toContainEqual(
expect(rows).toEqual([
expect.objectContaining({
key: "openai-codex/gpt-5.4",
available: true,
key: "openai-codex/gpt-5.3-codex",
}),
);
]);
});
it("exits with an error when configured-mode listing has no model registry", async () => {

View File

@ -1,5 +1,6 @@
import type { Api, Model } from "@mariozechner/pi-ai";
import type { ModelRegistry } from "@mariozechner/pi-coding-agent";
import { loadModelCatalog } from "../../agents/model-catalog.js";
import { parseModelRef } from "../../agents/model-selection.js";
import { resolveModelWithRegistry } from "../../agents/pi-embedded-runner/model.js";
import type { RuntimeEnv } from "../../runtime.js";
@ -69,6 +70,7 @@ export async function modelsListCommand(
const rows: ModelRow[] = [];
if (opts.all) {
const seenKeys = new Set<string>();
const sorted = [...models].toSorted((a, b) => {
const p = a.provider.localeCompare(b.provider);
if (p !== 0) {
@ -97,6 +99,46 @@ export async function modelsListCommand(
authStore,
}),
);
seenKeys.add(key);
}
if (modelRegistry) {
const catalog = await loadModelCatalog({ config: cfg });
for (const entry of catalog) {
if (providerFilter && entry.provider.toLowerCase() !== providerFilter) {
continue;
}
const key = modelKey(entry.provider, entry.id);
if (seenKeys.has(key)) {
continue;
}
const model = resolveModelWithRegistry({
provider: entry.provider,
modelId: entry.id,
modelRegistry,
cfg,
});
if (!model) {
continue;
}
if (opts.local && !isLocalBaseUrl(model.baseUrl)) {
continue;
}
const configured = configuredByKey.get(key);
rows.push(
toModelRow({
model,
key,
tags: configured ? Array.from(configured.tags) : [],
aliases: configured?.aliases ?? [],
availableKeys,
cfg,
authStore,
allowProviderAvailabilityFallback: !discoveredKeys.has(key),
}),
);
seenKeys.add(key);
}
}
} else {
const registry = modelRegistry;

View File

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

View File

@ -7,6 +7,7 @@ import {
clearRuntimeConfigSnapshot,
getRuntimeConfigSourceSnapshot,
loadConfig,
setRuntimeConfigSnapshotRefreshHandler,
setRuntimeConfigSnapshot,
writeConfigFile,
} from "./io.js";
@ -41,6 +42,7 @@ function createRuntimeConfig(): OpenClawConfig {
}
function resetRuntimeConfigState(): void {
setRuntimeConfigSnapshotRefreshHandler(null);
clearRuntimeConfigSnapshot();
clearConfigCache();
}
@ -96,4 +98,117 @@ 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,6 +140,22 @@ 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")
@ -1306,6 +1322,7 @@ 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();
@ -1356,6 +1373,12 @@ export function getRuntimeConfigSourceSnapshot(): OpenClawConfig | null {
return runtimeConfigSourceSnapshot;
}
export function setRuntimeConfigSnapshotRefreshHandler(
refreshHandler: RuntimeConfigSnapshotRefreshHandler | null,
): void {
runtimeConfigSnapshotRefreshHandler = refreshHandler;
}
export function loadConfig(): OpenClawConfig {
if (runtimeConfigSnapshot) {
return runtimeConfigSnapshot;
@ -1402,9 +1425,11 @@ export async function writeConfigFile(
): Promise<void> {
const io = createConfigIO();
let nextCfg = cfg;
if (runtimeConfigSnapshot && runtimeConfigSourceSnapshot) {
const runtimePatch = createMergePatch(runtimeConfigSnapshot, cfg);
nextCfg = coerceConfig(applyMergePatch(runtimeConfigSourceSnapshot, runtimePatch));
const hadRuntimeSnapshot = Boolean(runtimeConfigSnapshot);
const hadBothSnapshots = Boolean(runtimeConfigSnapshot && runtimeConfigSourceSnapshot);
if (hadBothSnapshots) {
const runtimePatch = createMergePatch(runtimeConfigSnapshot!, cfg);
nextCfg = coerceConfig(applyMergePatch(runtimeConfigSourceSnapshot!, runtimePatch));
}
const sameConfigPath =
options.expectedConfigPath === undefined || options.expectedConfigPath === io.configPath;
@ -1412,4 +1437,38 @@ 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

@ -5,6 +5,7 @@ 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;
@ -46,38 +47,6 @@ 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

@ -0,0 +1,35 @@
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

@ -0,0 +1,99 @@
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,6 +30,8 @@ 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) : [];
@ -119,6 +121,87 @@ 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() : "";
@ -151,6 +234,7 @@ 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;
@ -164,18 +248,41 @@ export async function runBrowserProxyCommand(paramsJSON?: string | null): Promis
}
const dispatcher = createBrowserRouteDispatcher(createBrowserControlContext());
const response = await withTimeout(
(signal) =>
dispatcher.dispatch({
method: method === "DELETE" ? "DELETE" : method === "POST" ? "POST" : "GET",
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,
path,
query,
body,
signal,
profile: path === "/profiles" ? undefined : profileForStatus || undefined,
timeoutMs,
wsBacked: isWsBackedBrowserProxyPath(path),
status,
}),
params.timeoutMs,
"browser proxy request",
);
{ cause: err },
);
}
if (response.status >= 400) {
const message =
response.body && typeof response.body === "object" && "error" in response.body

View File

@ -1,3 +1,7 @@
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";
@ -55,6 +59,51 @@ 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,6 +194,12 @@ 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

@ -0,0 +1,26 @@
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,10 +3,12 @@ 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 } from "../config/config.js";
import { loadConfig, type OpenClawConfig, writeConfigFile } from "../config/config.js";
import { withTempHome } from "../config/home-env.test-harness.js";
import {
activateSecretsRuntimeSnapshot,
clearSecretsRuntimeSnapshot,
getActiveSecretsRuntimeSnapshot,
prepareSecretsRuntimeSnapshot,
} from "./runtime.js";
@ -527,6 +529,248 @@ 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`,
{ 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,6 +8,7 @@ import {
} from "../agents/auth-profiles.js";
import {
clearRuntimeConfigSnapshot,
setRuntimeConfigSnapshotRefreshHandler,
setRuntimeConfigSnapshot,
type OpenClawConfig,
} from "../config/config.js";
@ -34,7 +35,18 @@ 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 {
@ -48,6 +60,22 @@ 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()));
@ -57,6 +85,17 @@ 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;
@ -104,23 +143,61 @@ export async function prepareSecretsRuntimeSnapshot(params: {
});
}
return {
const snapshot = {
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 {
return activeSnapshot ? cloneSnapshot(activeSnapshot) : null;
if (!activeSnapshot) {
return null;
}
const snapshot = cloneSnapshot(activeSnapshot);
if (activeRefreshContext) {
preparedSnapshotRefreshContext.set(snapshot, cloneRefreshContext(activeRefreshContext));
}
return snapshot;
}
export function resolveCommandSecretsFromActiveRuntimeSnapshot(params: {
@ -155,7 +232,5 @@ export function resolveCommandSecretsFromActiveRuntimeSnapshot(params: {
}
export function clearSecretsRuntimeSnapshot(): void {
activeSnapshot = null;
clearRuntimeConfigSnapshot();
clearRuntimeAuthProfileStoreSnapshots();
clearActiveSecretsRuntimeState();
}

View File

@ -11,6 +11,39 @@ 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;
@ -52,41 +85,17 @@ 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,
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,
}),
}),
...routeKeys,
};
try {